howler-sentinel-plugin 0.2.0.dev170__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.
@@ -0,0 +1,415 @@
1
+ import json
2
+ import logging
3
+ from datetime import datetime
4
+ from typing import Any, Optional
5
+
6
+ from dateutil import parser
7
+ from howler.common.exceptions import NonRecoverableError
8
+ from howler.config import config
9
+
10
+ from sentinel.mapping.xdr_alert_evidence import default_unknown_evidence, evidence_function_map
11
+
12
+ # Configure logger
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class XDRAlert:
17
+ """Class to handle mapping of Graph alerts to Howler hits."""
18
+
19
+ DEFAULT_CUSTOMER_NAME = "Unknown Customer"
20
+
21
+ def __init__(self, tid_mapping: Optional[dict[str, str]] = None, default_customer_name: Optional[str] = None):
22
+ """Initialize XDRAlert mapper with optional tenant ID mapping and customer name.
23
+
24
+ Args:
25
+ tid_mapping: Dictionary mapping tenant IDs to customer names. Defaults to empty dict.
26
+ default_customer_name: Default customer name when TID mapping lookup fails.
27
+ Defaults to DEFAULT_CUSTOMER_NAME.
28
+ """
29
+ # Allow overriding TID mapping and default customer name
30
+ self.tid_mapping = tid_mapping or {}
31
+ self.default_customer_name = default_customer_name or self.DEFAULT_CUSTOMER_NAME
32
+
33
+ def get_customer_name(self, tid: str) -> str:
34
+ """Get customer name from tenant ID mapping.
35
+
36
+ Args:
37
+ tid: Tenant ID to look up in the mapping.
38
+
39
+ Returns:
40
+ Customer name if found in mapping, otherwise returns default customer name.
41
+ """
42
+ return self.tid_mapping.get(tid, self.default_customer_name)
43
+
44
+ def map_severity(self, graph_severity: int) -> int:
45
+ """Map Microsoft Graph severity level to Howler severity score.
46
+
47
+ Converts Graph API severity integers (0-3) to Howler severity scores (10-75).
48
+ Higher Graph severity values map to higher Howler scores.
49
+
50
+ Args:
51
+ graph_severity: Graph API severity level (0=low, 1=medium, 2=high, 3=critical).
52
+
53
+ Returns:
54
+ Howler severity score (10, 25, 50, or 75). Defaults to 50 for unknown values.
55
+ """
56
+ severity = {
57
+ 3: 75,
58
+ 2: 50,
59
+ 1: 25,
60
+ 0: 10,
61
+ }
62
+
63
+ return severity.get(graph_severity, 50)
64
+
65
+ def map_status(self, graph_status: int) -> str:
66
+ """Map Microsoft Graph alert status to Howler status string.
67
+
68
+ Converts Graph API status integers to Howler-compatible status strings.
69
+
70
+ Args:
71
+ graph_status: Graph API status integer (1=new, 2=in-progress, 3=resolved).
72
+
73
+ Returns:
74
+ Howler status string ("open", "in-progress", or "resolved").
75
+ Defaults to "open" for unknown values.
76
+ """
77
+ status = {
78
+ 1: "open",
79
+ 2: "in-progress",
80
+ 3: "resolved",
81
+ }
82
+
83
+ return status.get(graph_status, "open")
84
+
85
+ def map_service_source(self, graph_service_source: int) -> str:
86
+ """Map Microsoft Graph service source ID to human-readable service name.
87
+
88
+ Converts Graph API service source integers to descriptive service names
89
+ for various Microsoft Defender and security services.
90
+
91
+ Args:
92
+ graph_service_source: Graph API service source integer identifier.
93
+
94
+ Returns:
95
+ Human-readable service name string. Returns "Unknown Service Source"
96
+ for unmapped values. Note: ID 9 is reserved by Graph as "UnknownFutureValue".
97
+ """
98
+ service_map = {
99
+ 1: "Microsoft Defender for Endpoint",
100
+ 2: "Microsoft Defender for Identity",
101
+ 3: "Microsoft Defender for Cloud Apps",
102
+ 4: "Microsoft Defender for Office 365",
103
+ 5: "Microsoft 365 Defender",
104
+ 6: "Azure AD Identity Protection",
105
+ 7: "Microsoft App Governance",
106
+ 8: "Data Loss Prevention",
107
+ # Yes, 9 should be here, but Graph has reserved it as "UnknownFutureValue"
108
+ 10: "Microsoft Defender for Cloud",
109
+ 11: "Microsoft Sentinel",
110
+ }
111
+ return service_map.get(graph_service_source, "Unknown Service Source")
112
+
113
+ def map_classification(self, graph_classification: str) -> str:
114
+ """Map Microsoft Graph alert classification to Howler assessment classification.
115
+
116
+ Converts Graph API classification strings or integers to Howler-compatible
117
+ assessment values. Handles both string and integer formats due to
118
+ serialization variations in Graph alert data.
119
+
120
+ Args:
121
+ graph_classification: Graph API classification value (string or int).
122
+ String values include: "informationalExpectedActivity", "falsePositive",
123
+ "truePositive", "unknown", etc.
124
+ Integer values: 0=ambiguous, 1=false-positive, 2=compromise,
125
+ 3=legitimate, 4=unknownFutureValue.
126
+
127
+ Returns:
128
+ Howler assessment classification ("legitimate", "false-positive",
129
+ "compromise", or "ambiguous"). Defaults to "ambiguous" for unknown values.
130
+
131
+ Note:
132
+ TODO: Evaluate if this field should be set for new alerts.
133
+ """
134
+ # Added integer values since the serialization of the graph alert is emitting integers at some point
135
+ classification = {
136
+ "informationalExpectedActivity": "legitimate",
137
+ "falsePositive": "false-positive",
138
+ "truePositive": "compromise",
139
+ "unknown": "ambiguous",
140
+ "TRUEPOSITIVE": "compromise",
141
+ "FALSEPOSITIVE": "false-positive",
142
+ "UNDETERMINED": "ambiguous",
143
+ "SECURITYTEST": "legitimate",
144
+ 0: "ambiguous",
145
+ 1: "false-positive",
146
+ 2: "compromise",
147
+ 3: "legitimate",
148
+ 4: "ambiguous", # unknownFutureValue
149
+ }
150
+ # TODO evaluate if we should set this field if new alert
151
+ return classification.get(graph_classification, "ambiguous")
152
+
153
+ def map_alert(self, graph_alert: dict[str, Any], customer_id: str) -> Optional[dict[str, Any]]:
154
+ """Transform a Microsoft Graph alert into a Howler hit format.
155
+
156
+ This is the main mapping function that converts a Graph API alert object
157
+ into the Howler hit format, including all necessary field mappings,
158
+ evidence processing, and metadata extraction.
159
+
160
+ Args:
161
+ graph_alert: Dictionary containing the Graph API alert data.
162
+ customer_id: Tenant/customer identifier for organization mapping.
163
+
164
+ Returns:
165
+ Dictionary representing a Howler hit with all mapped fields, or None
166
+ if the alert cannot be processed (e.g., None input, unknown customer).
167
+
168
+ Note:
169
+ Calls several helper methods to populate specific sections of the hit:
170
+ _map_timestamps, _map_graph_host_link, _populate_event_provider,
171
+ _populate_comments, and _map_evidence.
172
+ """
173
+ # Handle None input gracefully
174
+ if graph_alert is None:
175
+ logger.warning("Received None graph_alert, cannot process")
176
+ return None
177
+
178
+ customer_name = self.get_customer_name(customer_id)
179
+ if customer_name == self.DEFAULT_CUSTOMER_NAME:
180
+ logger.warning("Customer name not found for tenant ID: %s", graph_alert.get("tenantId", ""))
181
+ return None
182
+
183
+ alert_id = graph_alert.get("id", "")
184
+ created = graph_alert.get("createdDateTime", datetime.now().isoformat())
185
+ severity = self.map_severity(graph_alert.get("severity", "medium"))
186
+ status = self.map_status(graph_alert.get("status", "new"))
187
+ display_name = graph_alert.get("title", "MSGraph")
188
+
189
+ victim_labels = []
190
+
191
+ if graph_alert.get("os"):
192
+ victim_labels.append(graph_alert.get("os"))
193
+ if graph_alert.get("relatedUser", {}).get("userName"):
194
+ victim_labels.append(graph_alert.get("relatedUser", {}).get("userName"))
195
+ if graph_alert.get("computerDnsName"):
196
+ victim_labels.append(graph_alert.get("computerDnsName"))
197
+
198
+ # Get assignment and remove "User-" prefix if present
199
+ assigned_to = graph_alert.get("assignedTo", "unassigned")
200
+ if isinstance(assigned_to, str) and assigned_to.startswith("User-"):
201
+ assigned_to = assigned_to[5:]
202
+
203
+ # Get classification for conditional assessment
204
+ classification = graph_alert.get("classification")
205
+
206
+ howler_hit = {
207
+ "timestamp": created,
208
+ "message": graph_alert.get("recommendedActions", ""),
209
+ "organization": {"name": customer_name, "id": customer_id},
210
+ "howler": {
211
+ "analytic": "MSGraph",
212
+ "score": severity / 100.0,
213
+ "status": status,
214
+ "detection": display_name,
215
+ "outline": {
216
+ "summary": graph_alert.get("description", ""),
217
+ "indicators": [],
218
+ "threat": graph_alert.get("threatDisplayName", ""),
219
+ "target": graph_alert.get("computerDnsName", ""),
220
+ },
221
+ "assignment": assigned_to,
222
+ "escalation": "hit",
223
+ "is_bundle": False,
224
+ "bundle_size": 0,
225
+ "labels": {
226
+ "insight": [],
227
+ "mitigation": [],
228
+ "assignments": [],
229
+ "campaign": [],
230
+ "victim": victim_labels,
231
+ "threat": [],
232
+ "operation": [],
233
+ "generic": [],
234
+ },
235
+ "data": [json.dumps(graph_alert)],
236
+ },
237
+ "evidence": {"data": []},
238
+ "event": {
239
+ "created": created,
240
+ "kind": "alert",
241
+ "category": ["threat"],
242
+ "type": ["indicator"],
243
+ "severity": severity,
244
+ "action": graph_alert.get("title", ""),
245
+ "provider": self.map_service_source(graph_alert.get("detectionSource", "")),
246
+ "reason": display_name,
247
+ "risk_score": severity / 100.0,
248
+ },
249
+ "rule": {
250
+ "id": alert_id,
251
+ },
252
+ }
253
+
254
+ # Add assessment conditionally if classification is not null
255
+ if classification is not None:
256
+ howler_hit["howler"]["assessment"] = self.map_classification(classification)
257
+
258
+ # Call mapping helper methods
259
+ self._map_timestamps(graph_alert, howler_hit)
260
+ self._map_graph_host_link(graph_alert, howler_hit)
261
+ self._populate_event_provider(howler_hit)
262
+ self._populate_comments(graph_alert, howler_hit)
263
+ self._map_evidence(graph_alert, howler_hit)
264
+
265
+ return howler_hit
266
+
267
+ def _map_timestamps(self, graph_alert: dict[str, Any], howler_hit: dict[str, Any]) -> None:
268
+ """Extract and map timestamp fields from Graph alert to Howler hit event fields.
269
+
270
+ Processes various timestamp fields from the Graph alert and maps them to
271
+ appropriate Howler event fields. Handles timestamp parsing and validation.
272
+
273
+ Args:
274
+ graph_alert: Dictionary containing the Graph API alert data.
275
+ howler_hit: Dictionary representing the Howler hit being constructed.
276
+
277
+ Note:
278
+ Maps createdDateTime to event.created, firstActivityDateTime to event.start,
279
+ and lastActivityDateTime to event.end. Invalid timestamps are logged as warnings.
280
+ """
281
+ # Add all timestamps from Graph to event object
282
+ for time_field in [
283
+ "createdDateTime",
284
+ "lastUpdateDateTime",
285
+ "firstActivityDateTime",
286
+ "lastActivityDateTime",
287
+ ]:
288
+ if time_field in graph_alert and graph_alert[time_field]:
289
+ try:
290
+ timestamp = graph_alert[time_field]
291
+ dt_obj = parser.isoparse(timestamp)
292
+ # Map specific timestamps to Howler fields
293
+ if time_field == "createdDateTime":
294
+ howler_hit["event"]["created"] = dt_obj.isoformat()
295
+ elif time_field == "firstActivityDateTime":
296
+ howler_hit["event"]["start"] = dt_obj.isoformat()
297
+ elif time_field == "lastActivityDateTime":
298
+ howler_hit["event"]["end"] = dt_obj.isoformat()
299
+ except Exception:
300
+ logger.warning("Invalid timestamp format for %s: %s", time_field, graph_alert[time_field])
301
+
302
+ def _map_graph_host_link(self, graph_alert: dict[str, Any], howler_hit: dict[str, Any]) -> None:
303
+ """Add Microsoft XDR portal link to Howler hit for easy navigation.
304
+
305
+ Creates a clickable link in the Howler hit that opens the alert in the
306
+ Microsoft XDR portal for detailed investigation.
307
+
308
+ Args:
309
+ graph_alert: Dictionary containing the Graph API alert data.
310
+ howler_hit: Dictionary representing the Howler hit being constructed.
311
+
312
+ Note:
313
+ Link is only added if alertWebUrl is present in the Graph alert.
314
+ Uses the same logic as implemented in xdr_incident.py for consistency.
315
+ """
316
+ link: dict[str, str] = {
317
+ "icon": "https://security.microsoft.com/favicon.ico",
318
+ "title": "Open in Microsoft XDR portal",
319
+ "href": graph_alert.get("alertWebUrl", ""),
320
+ }
321
+ if graph_alert.get("alertWebUrl"):
322
+ howler_hit["howler"]["links"] = howler_hit["howler"].get("links", [])
323
+ howler_hit["howler"]["links"].append(link)
324
+
325
+ def _populate_event_provider(self, howler_hit: dict[str, Any]) -> None:
326
+ """Ensure the event provider field is populated with a default value.
327
+
328
+ Sets a default provider name if the event.provider field is missing or empty.
329
+ This ensures consistent provider identification across all processed alerts.
330
+
331
+ Args:
332
+ howler_hit: Dictionary representing the Howler hit being constructed.
333
+
334
+ Note:
335
+ Defaults to "MSGraphAlertCollector" when no provider is specified.
336
+ """
337
+ if "provider" not in howler_hit["event"] or not howler_hit["event"]["provider"]:
338
+ howler_hit["event"]["provider"] = "MSGraphAlertCollector"
339
+
340
+ def _populate_comments(self, graph_alert: dict[str, Any], howler_hit: dict[str, Any]) -> None:
341
+ """Extract and convert Graph alert comments to Howler hit comment format.
342
+
343
+ Processes comments from the Graph alert and converts them to the Howler
344
+ comment structure, handling various comment field name variations and
345
+ adding import attribution.
346
+
347
+ Args:
348
+ graph_alert: Dictionary containing the Graph API alert data.
349
+ howler_hit: Dictionary representing the Howler hit being constructed.
350
+
351
+ Note:
352
+ Handles field name variations (comment/Comment, createdBy/createdByDisplayName,
353
+ createdTime/createdDateTime). Invalid comments are logged and skipped.
354
+ All imported comments are marked with Microsoft XDR attribution.
355
+ """
356
+ if "comments" in graph_alert and isinstance(graph_alert["comments"], list):
357
+ comments = []
358
+
359
+ for comment in graph_alert.get("comments", []):
360
+ values = [
361
+ comment.get("comment", comment.get("Comment", None)),
362
+ comment.get("createdBy", comment.get("createdByDisplayName", None)),
363
+ comment.get("createdTime", comment.get("createdDateTime", None)),
364
+ ]
365
+ if not all(values):
366
+ logger.info("Invalid comment format in alert: %s", comment)
367
+ continue
368
+ comments.append(
369
+ {
370
+ "value": values[0],
371
+ "user": f"{values[1]}\n\n---\n\n*(Imported from Microsoft XDR)",
372
+ "timestamp": values[2],
373
+ }
374
+ )
375
+ if "comment" not in howler_hit["howler"]:
376
+ howler_hit["howler"]["comment"] = []
377
+ howler_hit["howler"]["comment"].extend(comments)
378
+
379
+ def _map_evidence(self, graph_alert: dict[str, Any], howler_hit: dict[str, Any]) -> None:
380
+ """Process and map evidence objects from Graph alert to Howler evidence format.
381
+
382
+ Extracts evidence items from the Graph alert and converts them using
383
+ specialized evidence mapping functions based on the evidence type.
384
+ Requires the evidence plugin to be enabled in Howler configuration.
385
+
386
+ Args:
387
+ graph_alert: Dictionary containing the Graph API alert data.
388
+ howler_hit: Dictionary representing the Howler hit being constructed.
389
+
390
+ Raises:
391
+ NonRecoverableError: If the evidence plugin is not enabled in configuration.
392
+
393
+ Note:
394
+ Uses evidence type from @odata.type field to determine appropriate
395
+ mapping function. Falls back to default_unknown_evidence for unmapped
396
+ or failed evidence types.
397
+ """
398
+ if "evidence" not in config.core.plugins:
399
+ raise NonRecoverableError("Sentinel requires the evidence plugin to be enabled")
400
+ if "evidence" not in howler_hit or not isinstance(howler_hit["evidence"], list):
401
+ howler_hit["evidence"] = []
402
+
403
+ for evidence in graph_alert.get("evidence", []):
404
+ odata = evidence.get("@odata.type", "")
405
+ if not odata:
406
+ continue
407
+ func_name = odata.split(".")[-1]
408
+ evidence_func = evidence_function_map.get(func_name)
409
+ mapped_evidence = None
410
+ if evidence_func:
411
+ mapped_evidence = evidence_func(evidence)
412
+ if not isinstance(mapped_evidence, dict):
413
+ logger.warning("Evidence mapping failed or returned non-dict for type: %s, using default.", odata)
414
+ mapped_evidence = default_unknown_evidence(evidence)
415
+ howler_hit["evidence"].append(mapped_evidence)