main
py 1,097 lines 51.7 KB
Raw
1 import json
2 import re
3 from datetime import datetime
4 from datetime import timedelta
5 from typing import Any
6 from typing import Dict
7 from typing import List
8 from typing import Optional
9 from typing import Union
10
11 from loguru import logger
12 from sqlalchemy import func
13 from sqlalchemy import select
14 from sqlalchemy import update
15 from sqlalchemy.ext.asyncio import AsyncSession
16
17 from app.connectors.wazuh_indexer.utils.universal import (
18 create_wazuh_indexer_client_async,
19 )
20 from app.db.universal_models import Agents
21 from app.incidents.models import VeloSigmaExclusion
22 from app.incidents.schema.db_operations import AlertTagCreate
23 from app.incidents.schema.db_operations import CommentCreate
24 from app.incidents.schema.incident_alert import CreateAlertRequest
25 from app.incidents.schema.incident_alert import CreatedAlertPayload
26 from app.incidents.schema.velo_sigma import DefenderEvent
27 from app.incidents.schema.velo_sigma import GenericEvent
28 from app.incidents.schema.velo_sigma import PowerShellEvent
29 from app.incidents.schema.velo_sigma import SysmonEvent
30 from app.incidents.schema.velo_sigma import VelociraptorSigmaAlert
31 from app.incidents.schema.velo_sigma import VelociraptorSigmaAlertResponse
32 from app.incidents.schema.velo_sigma import VeloSigmaExclusionCreate
33 from app.incidents.services.db_operations import add_alert_tag_if_not_exists
34 from app.incidents.services.db_operations import create_comment
35 from app.incidents.services.incident_alert import create_alert
36 from app.incidents.services.incident_alert import create_alert_full
37
38
39 class VeloSigmaExclusionService:
40 """Service for managing and checking Velociraptor Sigma exclusions."""
41
42 def __init__(self, session: AsyncSession):
43 self.session = session
44
45 async def check_exclusions(self, alert: VelociraptorSigmaAlert) -> Optional[VeloSigmaExclusion]:
46 """
47 Check if the given alert matches any exclusion rules.
48
49 Args:
50 alert: The Velociraptor Sigma alert to check
51
52 Returns:
53 The first matching exclusion rule, or None if no match is found
54 """
55 # Get all enabled exclusions
56 stmt = select(VeloSigmaExclusion).where(VeloSigmaExclusion.enabled == True)
57 result = await self.session.execute(stmt)
58 exclusions = result.scalars().all()
59
60 if not exclusions:
61 return None
62
63 # Parse the event data
64 try:
65 parsed_event = alert.get_parsed_event()
66 logger.debug(f"Checking exclusions for alert | Channel: {alert.channel} | Type: {type(parsed_event).__name__}")
67 logger.debug(f"Parsed event: {parsed_event}")
68 event_data = {}
69
70 # Extract event data fields from different event types
71 if hasattr(parsed_event, "EventData"):
72 # First add all top-level fields
73 for attr_name in dir(parsed_event.EventData):
74 if not attr_name.startswith("_") and not callable(getattr(parsed_event.EventData, attr_name)):
75 try:
76 value = getattr(parsed_event.EventData, attr_name)
77 if not callable(value):
78 event_data[attr_name] = str(value)
79 except Exception:
80 pass
81
82 # Special handling for PowerShell ContextInfo which contains Host Application
83 if hasattr(parsed_event.EventData, "ContextInfo") and parsed_event.EventData.ContextInfo:
84 # Parse the ContextInfo string which contains multiple lines of key-value pairs
85 context_info = parsed_event.EventData.ContextInfo
86 logger.debug(f"Found ContextInfo in PowerShell event: {context_info}")
87
88 for line in context_info.splitlines():
89 line = line.strip()
90 if line and " = " in line:
91 key, value = line.split(" = ", 1)
92 key = key.strip()
93 # Add these fields with their original names for direct matching
94 event_data[key] = value.strip()
95 # Also add common CamelCase variations to make matching more flexible
96 if " " in key:
97 # Convert "Host Application" to "HostApplication"
98 camel_key = "".join(word.capitalize() for word in key.split())
99 camel_key = camel_key[0].lower() + camel_key[1:] # lowerCamelCase
100 event_data[camel_key] = value.strip()
101
102 # Log the extracted field names and values for debugging
103 for key, value in event_data.items():
104 logger.debug(f"Extracted field: {key} = {value}")
105
106 except Exception as e:
107 logger.error(f"Error parsing event data: {str(e)}")
108 # If we can't parse the event, we won't exclude it
109 return None
110
111 # Check each exclusion against this alert
112 for exclusion in exclusions:
113 logger.debug(f"Checking exclusion: {exclusion.name} (ID: {exclusion.id})")
114 if await self._matches_exclusion(alert, event_data, exclusion):
115 # Update match statistics
116 await self._update_exclusion_stats(exclusion.id)
117 return exclusion
118
119 return None
120
121 async def _matches_exclusion(self, alert: VelociraptorSigmaAlert, event_data: Dict[str, str], exclusion: VeloSigmaExclusion) -> bool:
122 """Check if the alert matches the given exclusion rule."""
123 # Check customer code if specified
124 if exclusion.customer_code and exclusion.customer_code != self._get_customer_code(alert):
125 logger.debug(f"Customer code mismatch: rule={exclusion.customer_code}, alert={self._get_customer_code(alert)}")
126 return False
127
128 # Check channel if specified
129 if exclusion.channel and exclusion.channel != alert.channel:
130 logger.debug(f"Channel mismatch: rule={exclusion.channel}, alert={alert.channel}")
131 return False
132
133 # Check title if specified
134 if exclusion.title and exclusion.title != alert.title:
135 logger.debug(f"Title mismatch: rule={exclusion.title}, alert={alert.title}")
136 return False
137
138 # Check field matches if specified
139 if exclusion.field_matches:
140 for field_name, field_value in exclusion.field_matches.items():
141 # Special handling for Host Application field
142 if field_name.lower() == "hostapplication" and field_name not in event_data:
143 # Try common variations
144 alternate_keys = ["Host Application", "HostApplication", "hostApplication"]
145 found = False
146 for alt_key in alternate_keys:
147 if alt_key in event_data:
148 field_name = alt_key # Use the key that exists in the data
149 found = True
150 break
151 if not found:
152 logger.debug(f"Field '{field_name}' and its variations not found in event data")
153 return False
154
155 # Direct match
156 if field_name in event_data:
157 event_value = event_data[field_name]
158 logger.debug(f"Checking field match: {field_name}={field_value} against {event_value}")
159
160 # Support exact match or regex match
161 if isinstance(field_value, str):
162 if field_value.startswith("regex:"):
163 # Remove the regex: prefix and try to match
164 regex_pattern = field_value[6:]
165 # Special handling for path-based regex patterns
166 if (
167 "path" in field_name.lower()
168 or "file" in field_name.lower()
169 or "\\" in regex_pattern
170 or "/" in regex_pattern
171 ):
172 try:
173 # Normalize paths for comparison by converting all to lowercase and standardizing backslashes
174 pattern = regex_pattern.lower().replace("\\\\", "\\")
175 value = event_value.lower().replace("\\\\", "\\")
176
177 # Remove the regex: prefix if present
178 if pattern.startswith("regex:"):
179 pattern = pattern[6:]
180
181 # Handle escaped parentheses in Windows paths
182 pattern = pattern.replace("\\(", "(").replace("\\)", ")")
183
184 # Convert the wildcard pattern to proper regex format
185 # Escape special regex characters except for the wildcards we want to keep
186 pattern_parts = re.split(r"(\.\*)", pattern)
187 regex_parts = []
188
189 for i, part in enumerate(pattern_parts):
190 if part == ".*":
191 # Keep wildcards as-is
192 regex_parts.append(part)
193 else:
194 # Escape regex special characters but keep path separators
195 escaped = re.escape(part)
196 regex_parts.append(escaped)
197
198 pattern_regex = "".join(regex_parts)
199
200 # Force matching the entire string
201 pattern_regex = f"^{pattern_regex}$"
202
203 logger.debug(f"Path regex check: Pattern='{pattern}' → Regex='{pattern_regex}' vs Value='{value}'")
204
205 # Try the match
206 match_result = re.search(pattern_regex, value, re.IGNORECASE)
207 if match_result:
208 logger.debug(f"Path regex match succeeded! Match: {match_result.group(0)}")
209 return True
210 else:
211 logger.debug("Path regex match failed")
212 return False
213
214 except Exception as e:
215 logger.error(f"Error in path regex matching: {str(e)}")
216 return False
217 else:
218 # Standard regex for non-path values
219 try:
220 if not re.search(regex_pattern, event_value, re.IGNORECASE):
221 logger.debug(f"Regex pattern '{regex_pattern}' did not match '{event_value}'")
222 return False
223 else:
224 logger.debug(f"Regex pattern '{regex_pattern}' matched '{event_value}'")
225 except re.error as e:
226 logger.error(f"Invalid regex pattern in exclusion {exclusion.id}: {regex_pattern} - Error: {str(e)}")
227 return False
228 else:
229 # Case-insensitive path comparison for Windows paths
230 if "path" in field_name.lower() or "file" in field_name.lower() or "\\" in field_value or "/" in field_value:
231 # Log raw values for debugging
232 logger.debug(f"Before normalization - Rule: '{field_value}', Event: '{event_value}'")
233
234 # Use the path normalization helper
235 norm_field_value = self._normalize_windows_path(field_value)
236 norm_event_value = self._normalize_windows_path(event_value)
237
238 logger.debug(f"After normalization - Rule: '{norm_field_value}', Event: '{norm_event_value}'")
239
240 if norm_field_value != norm_event_value:
241 logger.debug(f"Path mismatch: rule='{norm_field_value}' event='{norm_event_value}'")
242 return False
243 else:
244 logger.debug(f"Path match found for: {field_name}")
245 else:
246 # Standard case-insensitive match for other fields
247 if field_value.lower() != event_value.lower():
248 logger.debug(f"Case-insensitive match failed: rule='{field_value}' event='{event_value}'")
249 return False
250 else:
251 # For non-string values (like lists or objects), convert to string for comparison
252 if str(field_value) != event_value:
253 logger.debug(f"String conversion match failed: rule='{str(field_value)}' event='{event_value}'")
254 return False
255 else:
256 # If field doesn't exist in the event and we're looking for it, no match
257 logger.debug(f"Field '{field_name}' not found in event data")
258 logger.debug(f"Available fields: {', '.join(event_data.keys())}")
259 return False
260
261 # If we passed all checks, this is a match
262 logger.info(f"Alert matched exclusion rule '{exclusion.name}' (ID: {exclusion.id})")
263 return True
264
265 def _normalize_windows_path(self, path: str, is_regex: bool = False) -> str:
266 """
267 Normalize Windows paths by converting all backslash variations to a consistent format.
268
269 Args:
270 path: The path string to normalize
271 is_regex: Whether the path contains regex patterns that should be preserved
272
273 Returns:
274 Normalized path with consistent backslashes and formatting
275 """
276 if not path:
277 return ""
278
279 # Convert to lowercase for case-insensitive comparison
280 normalized = path.lower()
281
282 if is_regex:
283 # Special handling for regex patterns
284 # First, temporarily replace regex character classes with placeholders
285 placeholders = {}
286
287 # Find all character classes like [^\\] or [\\w] and preserve them
288 char_class_pattern = r"(\[\^?[^\]]*\])"
289 char_classes = re.finditer(char_class_pattern, normalized)
290
291 for i, match in enumerate(char_classes):
292 placeholder = f"__REGEX_PLACEHOLDER_{i}__"
293 placeholders[placeholder] = match.group(0)
294 normalized = normalized.replace(match.group(0), placeholder)
295
296 # Use regex to replace any sequence of one or more backslashes with a single backslash
297 # This handles \, \\, \\\, \\\\, etc.
298 normalized = re.sub(r"\\+", r"\\", normalized)
299
300 # Handle escaped special characters in paths
301 normalized = normalized.replace("\\(", "(").replace("\\)", ")")
302 normalized = normalized.replace("\\[", "[").replace("\\]", "]")
303 normalized = normalized.replace("\\ ", " ")
304
305 # Remove any trailing backslash
306 if normalized.endswith("\\"):
307 normalized = normalized[:-1]
308
309 if is_regex:
310 # Restore the regex character classes with their original content
311 for placeholder, original in placeholders.items():
312 normalized = normalized.replace(placeholder, original)
313
314 # Log only in debug for excessive paths
315 if path != normalized:
316 path_preview = path[:20] + "..." if len(path) > 20 else path
317 norm_preview = normalized[:20] + "..." if len(normalized) > 20 else normalized
318 logger.debug(f"Path normalized: '{path_preview}''{norm_preview}'")
319
320 return normalized
321
322 async def _update_exclusion_stats(self, exclusion_id: int) -> None:
323 """Update the statistics for an exclusion after it matches."""
324 try:
325 stmt = (
326 update(VeloSigmaExclusion)
327 .where(VeloSigmaExclusion.id == exclusion_id)
328 .values(last_matched_at=datetime.utcnow(), match_count=VeloSigmaExclusion.match_count + 1)
329 )
330 await self.session.execute(stmt)
331 await self.session.commit()
332 except Exception as e:
333 logger.error(f"Error updating exclusion stats: {str(e)}")
334 # Don't raise the error, just log it
335
336 def _get_customer_code(self, alert: VelociraptorSigmaAlert) -> str:
337 """Extract or determine customer code from the alert."""
338 # This will depend on where customer code is stored in your alerts
339 # You might need to use your agent lookup logic here
340 # For now, we'll return a placeholder
341 return "unknown"
342
343 async def create_exclusion(self, exclusion: VeloSigmaExclusionCreate) -> VeloSigmaExclusion:
344 """Create a new exclusion rule."""
345 exclusion_data = exclusion.model_dump()
346
347 # Ensure created_by is set to something non-null
348 if not exclusion_data.get("created_by"):
349 exclusion_data["created_by"] = "system" # Default fallback
350
351 db_exclusion = VeloSigmaExclusion(**exclusion_data)
352 self.session.add(db_exclusion)
353 await self.session.commit()
354 await self.session.refresh(db_exclusion)
355 return db_exclusion
356
357 async def get_exclusion(self, exclusion_id: int) -> Optional[VeloSigmaExclusion]:
358 """Retrieve an exclusion by ID."""
359 stmt = select(VeloSigmaExclusion).where(VeloSigmaExclusion.id == exclusion_id)
360 result = await self.session.execute(stmt)
361 return result.scalar_one_or_none()
362
363 async def list_exclusions(self, skip: int = 0, limit: int = 100, enabled_only: bool = False) -> List[VeloSigmaExclusion]:
364 """List all exclusion rules with pagination."""
365 query = select(VeloSigmaExclusion)
366 if enabled_only:
367 query = query.where(VeloSigmaExclusion.enabled == True)
368 query = query.offset(skip).limit(limit)
369 result = await self.session.execute(query)
370 return result.scalars().all()
371
372 async def update_exclusion(self, exclusion_id: int, exclusion_data: Dict[str, Any]) -> Optional[VeloSigmaExclusion]:
373 """Update an existing exclusion rule."""
374 db_exclusion = await self.get_exclusion(exclusion_id)
375 if not db_exclusion:
376 return None
377
378 # Update only provided fields
379 for key, value in exclusion_data.items():
380 if hasattr(db_exclusion, key):
381 setattr(db_exclusion, key, value)
382
383 await self.session.commit()
384 await self.session.refresh(db_exclusion)
385 return db_exclusion
386
387 async def delete_exclusion(self, exclusion_id: int) -> bool:
388 """Delete an exclusion rule."""
389 db_exclusion = await self.get_exclusion(exclusion_id)
390 if not db_exclusion:
391 return False
392
393 await self.session.delete(db_exclusion)
394 await self.session.commit()
395 return True
396
397 async def list_exclusions_with_count(self, skip: int = 0, limit: int = 100, enabled_only: bool = False) -> tuple[list, int]:
398 """
399 List all exclusion rules with pagination and return total count.
400
401 Args:
402 skip: Number of items to skip
403 limit: Maximum number of items to return
404 enabled_only: If True, only return enabled exclusions
405
406 Returns:
407 Tuple of (list of exclusions, total count)
408 """
409 query = select(VeloSigmaExclusion)
410
411 if enabled_only:
412 query = query.where(VeloSigmaExclusion.enabled == True)
413
414 # Get total count first
415 count_query = select(func.count()).select_from(query.subquery())
416 # Change self.db to self.session
417 total_count = await self.session.scalar(count_query)
418
419 # Then get the paginated results
420 query = query.order_by(VeloSigmaExclusion.id.desc())
421 query = query.offset(skip).limit(limit)
422
423 # Change self.db to self.session
424 result = await self.session.execute(query)
425 exclusions = result.scalars().all()
426
427 return list(exclusions), total_count
428
429
430 class VelociraptorSigmaService:
431 """Service for handling Velociraptor Sigma alerts and their integration with Wazuh."""
432
433 def __init__(self, session: AsyncSession):
434 """Initialize with a database session."""
435 self.session = session
436
437 async def _create_fallback_alert(self, alert: VelociraptorSigmaAlert, result: Dict[str, Any]) -> None:
438 """
439 Create a fallback alert when no matching Wazuh event is found.
440 Uses create_alert_full to generate an alert directly from the Velociraptor data.
441 """
442 try:
443 # Extract timestamp from the event if available
444 timestamp = None
445 parsed_event = alert.get_parsed_event()
446 if hasattr(parsed_event, "System") and hasattr(parsed_event.System, "TimeCreated"):
447 timestamp = datetime.fromtimestamp(parsed_event.System.TimeCreated.SystemTime).isoformat()
448 else:
449 timestamp = datetime.utcnow().isoformat()
450
451 # Extract event information for context
452 event_context = {}
453 if hasattr(parsed_event, "EventData"):
454 # Try to convert EventData to dict for context
455 try:
456 event_context = parsed_event.EventData.model_dump()
457 except AttributeError:
458 # If not directly convertible, extract key attributes
459 event_context = {
460 "event_record_id": getattr(parsed_event.System, "EventRecordID", "Unknown"),
461 "channel": getattr(parsed_event.System, "Channel", "Unknown"),
462 "computer": getattr(parsed_event.System, "Computer", "Unknown"),
463 }
464
465 # Add alert metadata to context
466 event_context.update(
467 {
468 "alert_title": alert.title,
469 "alert_level": alert.level,
470 "alert_channel": alert.channel,
471 "alert_source": alert.source,
472 "computer": alert.computer,
473 "clientID": alert.clientID,
474 },
475 )
476
477 # Create a unique ID for this alert based on sourceRef and timestamp - Not using for now
478 # unique_id = f"{alert.sourceRef}_{int(datetime.utcnow().timestamp())}"
479
480 # Look up the customer code from Agents table using the clientID
481 customer_code = "not_found" # Default fallback
482 if alert.clientID:
483 # Query the Agents table to find matching agent by velociraptor_id
484 agent_query = select(Agents).where(Agents.velociraptor_id == alert.clientID)
485 agent_result = await self.session.execute(agent_query)
486 agent = agent_result.scalar_one_or_none()
487
488 if agent and agent.customer_code:
489 logger.info(f"Found agent details {agent}")
490 customer_code = agent.customer_code
491 # ! SOMETIMES VELOCIRAPTOR AND WAZUH ENUMERATE DIFFERENT HOSTNAMES ! #
492 # ! Due to this, we will use the agent.hostname as the asset name as this is what ! #
493 # ! used in the Wazuh events.!#
494 agent_name = agent.hostname
495 logger.info(f"Found customer code '{customer_code}' for clientID {alert.clientID}")
496 else:
497 logger.warning(f"No agent found with velociraptor_id '{alert.clientID}', using default customer code")
498 else:
499 logger.warning("No clientID provided in the alert, using default customer code")
500
501 # Create the alert using create_alert_full
502 alert_id = await create_alert_full(
503 alert_payload=CreatedAlertPayload(
504 alert_context_payload=event_context,
505 asset_payload=agent_name,
506 timefield_payload=timestamp,
507 alert_title_payload=alert.title,
508 source=alert.source,
509 index_id="not_applicable",
510 index_name="not_applicable",
511 ),
512 customer_code=customer_code, # Use the looked up customer code
513 session=self.session,
514 threshold_alert=False,
515 velo_sigma_alert=True,
516 )
517 result["alert_id"] = alert_id
518
519 # Add a comment with more context
520 event_type = type(parsed_event).__name__
521 await create_comment(
522 comment=CommentCreate(
523 alert_id=result["alert_id"],
524 comment=(
525 f"Velociraptor Sigma Alert (No Wazuh match found)\n"
526 f"Title: {alert.title}\n"
527 f"Channel: {alert.channel}\n"
528 f"Level: {alert.level}\n"
529 f"Computer: {alert.computer}\n"
530 f"Event Type: {event_type}\n"
531 f"Event Record ID: {result.get('event_record_id', 'Unknown')}\n"
532 f"Customer Code: {customer_code}\n"
533 ),
534 user_name="admin",
535 created_at=datetime.utcnow(),
536 ),
537 db=self.session,
538 )
539
540 # Add the full event payload as a separate comment
541 try:
542 # Convert event to string if it's an object or dictionary
543 event_payload = alert.event
544 if not isinstance(event_payload, str):
545 # Try to serialize using json
546 try:
547 event_payload = json.dumps(
548 event_payload,
549 default=lambda o: o.__dict__ if hasattr(o, "__dict__") else str(o),
550 indent=2,
551 )
552 except TypeError:
553 # If JSON serialization fails, use string representation
554 event_payload = str(event_payload)
555
556 await create_comment(
557 comment=CommentCreate(
558 alert_id=result["alert_id"],
559 comment=f"Full Event Payload:\n```\n{event_payload}\n```",
560 user_name="admin",
561 created_at=datetime.utcnow(),
562 ),
563 db=self.session,
564 )
565 logger.info(f"Added full event payload as comment to alert ID: {result['alert_id']}")
566 except Exception as e:
567 logger.error(f"Failed to add event payload as comment: {str(e)}")
568 logger.exception(e)
569
570 # Add tags
571 await add_alert_tag_if_not_exists(alert_tag=AlertTagCreate(alert_id=result["alert_id"], tag=f"{alert.type}"), db=self.session)
572 await add_alert_tag_if_not_exists(
573 alert_tag=AlertTagCreate(alert_id=result["alert_id"], tag="velociraptor-direct"),
574 db=self.session,
575 )
576
577 # Add event-specific tags
578 if "Sysmon" in alert.channel:
579 await add_alert_tag_if_not_exists(
580 alert_tag=AlertTagCreate(alert_id=result["alert_id"], tag="event_type:sysmon"),
581 db=self.session,
582 )
583 elif "Defender" in alert.channel:
584 await add_alert_tag_if_not_exists(
585 alert_tag=AlertTagCreate(alert_id=result["alert_id"], tag="event_type:defender"),
586 db=self.session,
587 )
588 elif "PowerShell" in alert.channel:
589 await add_alert_tag_if_not_exists(
590 alert_tag=AlertTagCreate(alert_id=result["alert_id"], tag="event_type:powershell"),
591 db=self.session,
592 )
593 else:
594 # Generic event type
595 await add_alert_tag_if_not_exists(
596 alert_tag=AlertTagCreate(alert_id=result["alert_id"], tag="event_type:generic"),
597 db=self.session,
598 )
599
600 logger.info(f"Created fallback CoPilot alert with ID: {result['alert_id']} for customer {customer_code}")
601 result["success"] = True
602
603 except Exception as e:
604 logger.error(f"Failed to create fallback CoPilot alert: {str(e)}")
605 logger.exception(e)
606 result["alert_id"] = None
607 result["success"] = False
608 result["reason"] = f"Failed to create fallback alert: {str(e)}"
609
610 async def process_alert(self, alert: VelociraptorSigmaAlert) -> VelociraptorSigmaAlertResponse:
611 """
612 Process a Velociraptor Sigma alert and create a corresponding CoPilot alert.
613
614 Args:
615 alert: The Velociraptor Sigma alert to process
616
617 Returns:
618 Response indicating the success or failure of the processing
619 """
620 try:
621 # Check exclusions first
622 exclusion_service = VeloSigmaExclusionService(self.session)
623 matching_exclusion = await exclusion_service.check_exclusions(alert)
624
625 if matching_exclusion:
626 # Alert is excluded, return a response indicating this
627 logger.info(f"Skipping alert processing: matched exclusion rule '{matching_exclusion.name}' (ID: {matching_exclusion.id})")
628 return VelociraptorSigmaAlertResponse(
629 success=True,
630 message=f"Alert excluded by rule: {matching_exclusion.name}",
631 alert_id=None,
632 excluded=True,
633 exclusion_id=matching_exclusion.id,
634 )
635
636 # Parse event and determine event type
637 result = await self._process_event_by_type(alert)
638
639 # Create an alert in CoPilot if the processing was successful
640 if result.get("success"):
641 await self._create_copilot_alert(alert, result)
642 else:
643 # If no Wazuh alert was found, try to create a fallback alert
644 logger.info("No matching Wazuh alert found, creating fallback alert...")
645 await self._create_fallback_alert(alert, result)
646
647 # Build response
648 return self._build_response(alert, result)
649
650 except Exception as e:
651 logger.error(f"Error processing Velociraptor Sigma alert: {str(e)}")
652 logger.exception(e)
653 return VelociraptorSigmaAlertResponse(success=False, message=f"Error: {str(e)}", alert_id=getattr(alert, "sourceRef", None))
654
655 async def _process_event_by_type(self, alert: VelociraptorSigmaAlert) -> Dict[str, Any]:
656 """Process event according to its type or channel."""
657 parsed_event = alert.get_parsed_event()
658 logger.debug(f"Processing alert | Channel: {alert.channel} | Type: {type(parsed_event).__name__}")
659
660 # Use type checking and channel fallback
661 if isinstance(parsed_event, SysmonEvent):
662 return await self._process_sysmon_event(alert, parsed_event)
663 elif isinstance(parsed_event, DefenderEvent):
664 return await self._process_defender_event(alert, parsed_event)
665 elif "Sysmon" in alert.channel:
666 logger.warning(f"Expected SysmonEvent but got {type(parsed_event).__name__} for Sysmon channel")
667 return await self._process_sysmon_event(alert, parsed_event)
668 elif "Defender" in alert.channel:
669 logger.warning(f"Expected DefenderEvent but got {type(parsed_event).__name__} for Defender channel")
670 return await self._process_defender_event(alert, parsed_event)
671 elif "PowerShell" in alert.channel:
672 logger.warning(f"Expected PowerShellEvent but got {type(parsed_event).__name__} for PowerShell channel")
673 return await self._process_powershell_event(alert, parsed_event)
674 else:
675 # Use a generic processor for unknown event types
676 logger.info(f"Using generic processor for channel: {alert.channel}")
677 return await self._process_generic_event(alert, parsed_event)
678
679 async def _process_sysmon_event(self, alert: VelociraptorSigmaAlert, parsed_event: Union[SysmonEvent, GenericEvent]) -> Dict[str, Any]:
680 """Process a Sysmon event."""
681 logger.info(f"Processing Sysmon event from channel: {alert.channel}")
682
683 try:
684 # Extract key fields safely
685 event_record_id = str(parsed_event.System.EventRecordID)
686
687 # Safely access EventData fields with fallbacks
688 event_data = parsed_event.EventData
689 rule_name = getattr(event_data, "RuleName", "Unknown Rule")
690 source_image = getattr(event_data, "SourceImage", "Unknown Source")
691 target_image = getattr(event_data, "TargetImage", "Unknown Target")
692 source_process_id = getattr(event_data, "SourceProcessId", 0)
693 source_user = getattr(event_data, "SourceUser", "Unknown User")
694
695 agent_name = alert.computer # Default to the computer name from the alert
696
697 if alert.clientID:
698 # Query the Agents table to find matching agent by velociraptor_id
699 agent_query = select(Agents).where(Agents.velociraptor_id == alert.clientID)
700 agent_result = await self.session.execute(agent_query)
701 agent = agent_result.scalar_one_or_none()
702
703 if agent and agent.hostname:
704 logger.info(f"Found agent details {agent}")
705 # ! SOMETIMES VELOCIRAPTOR AND WAZUH ENUMERATE DIFFERENT HOSTNAMES ! #
706 # ! Due to this, we will use the agent.hostname as the asset name as this is what ! #
707 # ! used in the Wazuh events.!#
708 agent_name = agent.hostname
709
710 # Fetch corresponding Wazuh alert
711 wazuh_event = await self._fetch_wazuh_alert(
712 agent_name=agent_name,
713 event_record_id=event_record_id,
714 index_pattern=alert.index_pattern,
715 )
716
717 # Build result
718 result = {
719 "event_record_id": event_record_id,
720 "rule_name": rule_name,
721 "source_image": source_image,
722 "target_image": target_image,
723 "source_process_id": source_process_id,
724 "source_user": source_user,
725 "wazuh_data": wazuh_event,
726 "success": wazuh_event is not None,
727 }
728
729 logger.info(f"Sysmon event processed | EventRecordID: {event_record_id} | Success: {result['success']}")
730 return result
731
732 except AttributeError as e:
733 logger.error(f"Failed to process Sysmon event - missing attribute: {str(e)}")
734 return {"success": False, "reason": f"Failed to process Sysmon event: {str(e)}"}
735
736 async def _process_defender_event(
737 self,
738 alert: VelociraptorSigmaAlert,
739 parsed_event: Union[DefenderEvent, GenericEvent],
740 ) -> Dict[str, Any]:
741 """Process a Windows Defender event."""
742 logger.info(f"Processing Defender event from channel: {alert.channel}")
743
744 try:
745 # Extract event record ID
746 event_record_id = str(parsed_event.System.EventRecordID)
747
748 agent_name = alert.computer # Default to the computer name from the alert
749
750 if alert.clientID:
751 # Query the Agents table to find matching agent by velociraptor_id
752 agent_query = select(Agents).where(Agents.velociraptor_id == alert.clientID)
753 agent_result = await self.session.execute(agent_query)
754 agent = agent_result.scalar_one_or_none()
755
756 if agent and agent.hostname:
757 logger.info(f"Found agent details {agent}")
758 # ! SOMETIMES VELOCIRAPTOR AND WAZUH ENUMERATE DIFFERENT HOSTNAMES ! #
759 # ! Due to this, we will use the agent.hostname as the asset name as this is what ! #
760 # ! used in the Wazuh events.!#
761 agent_name = agent.hostname
762
763 # Fetch corresponding Wazuh alert
764 wazuh_event = await self._fetch_wazuh_alert(
765 agent_name=agent_name,
766 event_record_id=event_record_id,
767 index_pattern=alert.index_pattern,
768 )
769
770 # Build basic result
771 result = {"event_record_id": event_record_id, "wazuh_data": wazuh_event, "success": wazuh_event is not None}
772
773 # Safely extract additional fields
774 event_data = parsed_event.EventData
775
776 if hasattr(event_data, "product_name"):
777 result["product_name"] = event_data.product_name
778
779 if hasattr(event_data, "threat_name"):
780 result["threat_name"] = event_data.threat_name
781
782 if hasattr(event_data, "severity_name"):
783 result["severity"] = event_data.severity_name
784
785 logger.info(f"Defender event processed | EventRecordID: {event_record_id} | Success: {result['success']}")
786 return result
787
788 except AttributeError as e:
789 logger.error(f"Failed to process Defender event - missing attribute: {str(e)}")
790 return {"success": False, "reason": f"Failed to process Defender event: {str(e)}"}
791
792 async def _process_powershell_event(
793 self,
794 alert: VelociraptorSigmaAlert,
795 parsed_event: Union[PowerShellEvent, GenericEvent],
796 ) -> Dict[str, Any]:
797 """Process a PowerShell event."""
798 logger.info(f"Processing PowerShell event from channel: {alert.channel}")
799
800 try:
801 # Extract event record ID
802 event_record_id = str(parsed_event.System.EventRecordID)
803
804 agent_name = alert.computer # Default to the computer name from the alert
805
806 if alert.clientID:
807 # Query the Agents table to find matching agent by velociraptor_id
808 agent_query = select(Agents).where(Agents.velociraptor_id == alert.clientID)
809 agent_result = await self.session.execute(agent_query)
810 agent = agent_result.scalar_one_or_none()
811
812 if agent and agent.hostname:
813 logger.info(f"Found agent details {agent}")
814 # ! SOMETIMES VELOCIRAPTOR AND WAZUH ENUMERATE DIFFERENT HOSTNAMES ! #
815 # ! Due to this, we will use the agent.hostname as the asset name as this is what ! #
816 # ! used in the Wazuh events.!#
817 agent_name = agent.hostname
818
819 # Fetch corresponding Wazuh alert
820 wazuh_event = await self._fetch_wazuh_alert(
821 agent_name=agent_name,
822 event_record_id=event_record_id,
823 index_pattern=alert.index_pattern,
824 )
825
826 # Build basic result
827 result = {"event_record_id": event_record_id, "wazuh_data": wazuh_event, "success": wazuh_event is not None}
828
829 # Safely extract PowerShell specific fields
830 event_data = parsed_event.EventData
831
832 # Add ScriptBlock details if available
833 if hasattr(event_data, "ScriptBlockText"):
834 result["script_block_text"] = event_data.ScriptBlockText
835 # Store only first 100 chars as a preview to avoid overwhelming logs
836 preview = event_data.ScriptBlockText[:100] + "..." if len(event_data.ScriptBlockText) > 100 else event_data.ScriptBlockText
837 result["script_preview"] = preview
838
839 if hasattr(event_data, "ScriptBlockId"):
840 result["script_block_id"] = event_data.ScriptBlockId
841
842 if hasattr(event_data, "MessageNumber") and hasattr(event_data, "MessageTotal"):
843 result["message_part"] = f"{event_data.MessageNumber} of {event_data.MessageTotal}"
844
845 # Add host information if available
846 if hasattr(event_data, "HostApplication"):
847 result["host_application"] = event_data.HostApplication
848
849 if hasattr(event_data, "CommandName"):
850 result["command_name"] = event_data.CommandName
851
852 logger.info(f"PowerShell event processed | EventRecordID: {event_record_id} | Success: {result['success']}")
853 return result
854
855 except AttributeError as e:
856 logger.error(f"Failed to process PowerShell event - missing attribute: {str(e)}")
857 return {"success": False, "reason": f"Failed to process PowerShell event: {str(e)}"}
858
859 async def _process_generic_event(self, alert: VelociraptorSigmaAlert, parsed_event: GenericEvent) -> Dict[str, Any]:
860 """Process a generic event that doesn't match known types."""
861 logger.info(f"Processing generic event from channel: {alert.channel}")
862
863 try:
864 # Extract event record ID if available
865 event_record_id = str(getattr(parsed_event.System, "EventRecordID", "unknown"))
866
867 agent_name = alert.computer # Default to the computer name from the alert
868
869 if alert.clientID:
870 # Query the Agents table to find matching agent by velociraptor_id
871 agent_query = select(Agents).where(Agents.velociraptor_id == alert.clientID)
872 agent_result = await self.session.execute(agent_query)
873 agent = agent_result.scalar_one_or_none()
874
875 if agent and agent.hostname:
876 logger.info(f"Found agent details {agent}")
877 # ! SOMETIMES VELOCIRAPTOR AND WAZUH ENUMERATE DIFFERENT HOSTNAMES ! #
878 # ! Due to this, we will use the agent.hostname as the asset name as this is what ! #
879 # ! used in the Wazuh events.!#
880 agent_name = agent.hostname
881
882 # Try to fetch corresponding Wazuh alert if we have an event record ID
883 wazuh_event = None
884 if event_record_id != "unknown":
885 wazuh_event = await self._fetch_wazuh_alert(
886 agent_name=agent_name,
887 event_record_id=event_record_id,
888 index_pattern=alert.index_pattern,
889 )
890
891 # Build basic result
892 result = {
893 "event_record_id": event_record_id,
894 "wazuh_data": wazuh_event,
895 "success": wazuh_event is not None,
896 "channel": alert.channel,
897 }
898
899 # Extract some generic system info
900 if hasattr(parsed_event, "System"):
901 system = parsed_event.System
902 if hasattr(system, "Channel"):
903 result["system_channel"] = system.Channel
904 if hasattr(system, "Provider") and hasattr(system.Provider, "Name"):
905 result["provider_name"] = system.Provider.Name
906 if hasattr(system, "EventID") and hasattr(system.EventID, "Value"):
907 result["event_id"] = system.EventID.Value
908
909 # Try to extract some event data if available
910 if hasattr(parsed_event, "EventData"):
911 try:
912 # Add the first few items from EventData to the result
913 event_data = parsed_event.EventData
914 event_data_dict = {}
915
916 # Get all attributes that aren't methods or private
917 for attr_name in dir(event_data):
918 if not attr_name.startswith("_") and not callable(getattr(event_data, attr_name)):
919 try:
920 value = getattr(event_data, attr_name)
921 if not callable(value): # Skip methods
922 event_data_dict[attr_name] = str(value)
923 except Exception as attr_error:
924 logger.warning(f"Failed to access attribute '{attr_name}': {str(attr_error)}")
925 # Skip attributes that can't be accessed
926 pass
927
928 # Add to result, limited to prevent overwhelming logs
929 result["event_data"] = {k: v for i, (k, v) in enumerate(event_data_dict.items()) if i < 10}
930
931 except Exception as e:
932 logger.warning(f"Failed to extract event data details: {e}")
933
934 logger.info(f"Generic event processed | EventRecordID: {event_record_id} | Success: {result['success']}")
935 return result
936
937 except Exception as e:
938 logger.error(f"Failed to process generic event: {str(e)}")
939 logger.exception(e)
940 return {"success": False, "reason": f"Failed to process generic event: {str(e)}"}
941
942 async def _create_copilot_alert(self, alert: VelociraptorSigmaAlert, result: Dict[str, Any]) -> None:
943 """Create an alert in CoPilot with comments and tags."""
944 try:
945 # Create the alert
946 wazuh_data = result["wazuh_data"]
947 alert_response = await create_alert(
948 alert=CreateAlertRequest(index_name=wazuh_data["index_name"], alert_id=wazuh_data["alert_id"]),
949 session=self.session,
950 )
951 result["alert_id"] = alert_response
952
953 # Add a comment
954 await create_comment(
955 comment=CommentCreate(
956 alert_id=result["alert_id"],
957 comment=f"Velociraptor Sigma: {alert.title} | {alert.channel}",
958 user_name="admin",
959 created_at=datetime.utcnow(),
960 ),
961 db=self.session,
962 )
963
964 # Add the full event payload as a separate comment
965 try:
966 # Convert event to string if it's an object or dictionary
967 event_payload = alert.event
968 if not isinstance(event_payload, str):
969 # Try to serialize using json
970 try:
971 event_payload = json.dumps(
972 event_payload,
973 default=lambda o: o.__dict__ if hasattr(o, "__dict__") else str(o),
974 indent=2,
975 )
976 except TypeError:
977 # If JSON serialization fails, use string representation
978 event_payload = str(event_payload)
979
980 await create_comment(
981 comment=CommentCreate(
982 alert_id=result["alert_id"],
983 comment=f"Full Event Payload:\n```\n{event_payload}\n```",
984 user_name="admin",
985 created_at=datetime.utcnow(),
986 ),
987 db=self.session,
988 )
989 logger.info(f"Added full event payload as comment to alert ID: {result['alert_id']}")
990 except Exception as e:
991 logger.error(f"Failed to add event payload as comment: {str(e)}")
992 logger.exception(e)
993
994 # Add a tag
995 await add_alert_tag_if_not_exists(alert_tag=AlertTagCreate(alert_id=result["alert_id"], tag=f"{alert.type}"), db=self.session)
996
997 logger.info(f"Created CoPilot alert with ID: {result['alert_id']}")
998
999 except Exception as e:
1000 logger.error(f"Failed to create CoPilot alert: {str(e)}")
1001 logger.exception(e)
1002 result["alert_id"] = None
1003
1004 def _build_response(self, alert: VelociraptorSigmaAlert, result: Dict[str, Any]) -> VelociraptorSigmaAlertResponse:
1005 """Build the response based on processing results."""
1006 success = result.get("success", False)
1007
1008 if not success:
1009 message = f"Failed to process {alert.channel} alert: {result.get('reason', 'Unknown error')}"
1010 logger.warning(f"{message} | EventRecordID: {result.get('event_record_id', 'Unknown')}")
1011 else:
1012 message = f"Successfully processed {alert.channel} alert"
1013 logger.info(f"{message} | EventRecordID: {result.get('event_record_id')} | AlertID: {result.get('alert_id')}")
1014
1015 return VelociraptorSigmaAlertResponse(success=success, message=message, alert_id=result.get("alert_id"))
1016
1017 async def _fetch_wazuh_alert(self, agent_name: str, event_record_id: str, index_pattern: str) -> Optional[Dict[str, Any]]:
1018 """Fetch alert data from Wazuh Indexer."""
1019 try:
1020 # Create client and prepare search parameters
1021 client = await create_wazuh_indexer_client_async("Wazuh-Indexer")
1022
1023 # Use ISO format for timestamps to avoid format errors - default to current time -1 hour
1024 # This is to ensure we are searching within the last hour
1025 # ! Might need to revisit this if the time window is too small ! #
1026 one_hour_ago = datetime.utcnow() - timedelta(hours=1)
1027 timestamp = one_hour_ago.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
1028
1029 # Build query
1030 query = self._build_wazuh_query(agent_name, event_record_id, timestamp)
1031 logger.debug(f"Searching Wazuh Indexer | Query: {query}")
1032
1033 # Execute search
1034 response = await self._execute_wazuh_search(client, query, index_pattern)
1035
1036 # Extract and return results
1037 return self._extract_wazuh_results(response)
1038
1039 except Exception as e:
1040 logger.error(f"Error fetching alert data: {str(e)}")
1041 logger.exception(e)
1042 return None
1043
1044 @staticmethod
1045 def _build_wazuh_query(agent_name: str, event_record_id: str, timestamp: str) -> Dict[str, Any]:
1046 """Build the OpenSearch query for finding the alert in Wazuh."""
1047 return {
1048 "bool": {
1049 "must": [{"term": {"agent_name": agent_name}}, {"term": {"data_win_system_eventRecordID": event_record_id}}],
1050 "filter": [{"range": {"timestamp": {"gte": timestamp}}}],
1051 },
1052 }
1053
1054 @staticmethod
1055 async def _execute_wazuh_search(client, query: Dict[str, Any], index_pattern: str) -> Dict[str, Any]:
1056 """Execute the search against the Wazuh Indexer."""
1057 try:
1058 response = await client.search(index=index_pattern, body={"query": query}, size=1, timeout="1m")
1059 logger.debug(f"Search response received | Status: {'hits' in response}")
1060 return response
1061 except Exception as search_error:
1062 logger.error(f"Search operation failed: {str(search_error)}")
1063 logger.exception(search_error)
1064 return {}
1065
1066 @staticmethod
1067 def _extract_wazuh_results(response: Dict[str, Any]) -> Optional[Dict[str, Any]]:
1068 """Extract the alert data from the Wazuh Indexer response."""
1069 if not response or "hits" not in response or "hits" not in response["hits"]:
1070 logger.warning("Invalid response structure from Wazuh Indexer")
1071 return None
1072
1073 hits = response["hits"]["hits"]
1074 if not hits:
1075 logger.warning("No matching alerts found in Wazuh Indexer")
1076 return None
1077
1078 # Get the first matching hit
1079 hit = hits[0]
1080
1081 # Extract index, document ID and source data
1082 index_name = hit.get("_index")
1083 alert_id = hit.get("_id")
1084 raw_alert = hit.get("_source", {})
1085
1086 # Enrich the raw alert with metadata needed for references
1087 raw_alert["index_name"] = index_name
1088 raw_alert["alert_id"] = alert_id
1089
1090 logger.debug(f"Successfully extracted raw alert data from index {index_name} with ID {alert_id}")
1091 return raw_alert
1092
1093
1094 async def create_velo_sigma_alert(alert: VelociraptorSigmaAlert, session: AsyncSession) -> VelociraptorSigmaAlertResponse:
1095 """Process a Velociraptor Sigma alert using the VelociraptorSigmaService."""
1096 service = VelociraptorSigmaService(session)
1097 return await service.process_alert(alert)