howler-sentinel-plugin 0.2.0.dev31__tar.gz → 0.2.0.dev87__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: howler-sentinel-plugin
3
- Version: 0.2.0.dev31
3
+ Version: 0.2.0.dev87
4
4
  Summary: A howler plugin for integration with Microsoft's Sentinel API
5
5
  License: MIT
6
6
  Author: CCCS
@@ -12,6 +12,7 @@ Classifier: Programming Language :: Python :: 3.10
12
12
  Classifier: Programming Language :: Python :: 3.11
13
13
  Classifier: Programming Language :: Python :: 3.12
14
14
  Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Dist: python-dateutil (>=2.9.0.post0,<3.0.0)
15
16
  Description-Content-Type: text/markdown
16
17
 
17
18
  # Howler Sentinel Plugin
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "howler-sentinel-plugin"
3
- version = "0.2.0.dev31"
3
+ version = "0.2.0.dev87"
4
4
  description = "A howler plugin for integration with Microsoft's Sentinel API"
5
5
  authors = [{ name = "CCCS", email = "analysis-development@cyber.gc.ca" }]
6
6
  license = { text = "MIT" }
@@ -146,6 +146,9 @@ python-dotenv = "^1.1.0"
146
146
  [tool.poetry.group.types.dependencies]
147
147
  types-mock = "^5.2.0.20250516"
148
148
 
149
+
150
+ [tool.poetry.dependencies]
151
+ python-dateutil = "^2.9.0.post0"
149
152
  [build-system]
150
153
  requires = ["poetry-core>=2.0.0,<3.0.0"]
151
154
  build-backend = "poetry.core.masonry.api"
@@ -0,0 +1,234 @@
1
+ """Sentiel Incident mapper for converting Microsoft Sentinel Sentiel Incidents to Howler bundles."""
2
+
3
+ import logging
4
+ from typing import Any, Optional
5
+
6
+ from dateutil import parser
7
+
8
+ # Use standard logging for now
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class SentinelIncident:
13
+ """Class to handle mapping of Sentiel Incidents to Howler bundles."""
14
+
15
+ DEFAULT_CUSTOMER_NAME = "Unknown Customer"
16
+
17
+ def __init__(self, tid_mapping: Optional[dict[str, str]] = None):
18
+ """Initialize the Sentiel Incident mapper.
19
+
20
+ Args:
21
+ tid_mapping: Mapping of tenant IDs to customer names
22
+ """
23
+ self.tid_mapping = tid_mapping or {}
24
+
25
+ # --- Public mapping methods ---
26
+
27
+ def map_incident_to_bundle(self, sentinel_incident: dict[str, Any]) -> Optional[dict[str, Any]]:
28
+ """Map an Sentiel Incident to a Howler bundle.
29
+
30
+ Args:
31
+ sentinel_incident (dict[str, Any]): The Sentiel Incident data from Microsoft Graph.
32
+
33
+ Returns:
34
+ Optional[dict[str, Any]]: Mapped bundle dictionary or None if mapping fails.
35
+
36
+ Example:
37
+ >>> mapper = SentinelIncident()
38
+ >>> bundle = mapper.map_incident_to_bundle(sentinel_incident)
39
+ """
40
+ try:
41
+ if not sentinel_incident:
42
+ logger.error("Empty incident data provided")
43
+ return None
44
+
45
+ required_fields = ["id", "tenantId", "createdDateTime"]
46
+ for field in required_fields:
47
+ if not sentinel_incident.get(field):
48
+ logger.warning("Missing required field '%s' in incident: %s", field, sentinel_incident)
49
+
50
+ tenant_id: str = sentinel_incident.get("tenantId", "")
51
+ customer_name: str = self.get_customer_name(tenant_id)
52
+
53
+ incident_id: Optional[str] = sentinel_incident.get("id")
54
+ status: str = sentinel_incident.get("status", "active")
55
+ display_name: str = sentinel_incident.get("displayName", "")
56
+ created_datetime: Optional[str] = sentinel_incident.get("createdDateTime")
57
+ assigned_to: Optional[str] = sentinel_incident.get("assignedTo")
58
+ classification: str = sentinel_incident.get("classification", "unknown")
59
+ severity: str = sentinel_incident.get("severity", "medium")
60
+ custom_tags: list[str] = sentinel_incident.get("customTags") or []
61
+ system_tags: list[str] = sentinel_incident.get("systemTags") or []
62
+ description: str = sentinel_incident.get("description", "")
63
+ resolving_comment: str = sentinel_incident.get("resolvingComment", "")
64
+ if not isinstance(custom_tags, list):
65
+ logger.warning("customTags is not a list: %s", custom_tags)
66
+ custom_tags = []
67
+ if not isinstance(system_tags, list):
68
+ logger.warning("systemTags is not a list: %s", system_tags)
69
+ system_tags = []
70
+
71
+ bundle: dict[str, Any] = {
72
+ "howler": {
73
+ "status": self.map_sentinel_status_to_howler(status),
74
+ "detection": display_name,
75
+ "assignment": self.map_sentinel_user_to_howler(assigned_to),
76
+ "score": self.map_severity_to_score(severity),
77
+ "outline.summary": description,
78
+ "rationale": resolving_comment,
79
+ "analytic": "MSGraph",
80
+ "is_bundle": True,
81
+ "bundle_size": 0,
82
+ "hits": [],
83
+ "labels.generic": self._build_labels(custom_tags, system_tags),
84
+ },
85
+ "organization": {"name": customer_name, "id": tenant_id},
86
+ "sentinel": {
87
+ "id": incident_id,
88
+ },
89
+ "evidence": {"cloud": {"account": {"id": tenant_id}}},
90
+ "event": {
91
+ "created": created_datetime,
92
+ "start": created_datetime,
93
+ "end": created_datetime,
94
+ },
95
+ }
96
+ self._map_graph_host_link(sentinel_incident, bundle)
97
+ self._map_timestamps(sentinel_incident, bundle)
98
+ # Add assessment conditionally if classification is not null
99
+ if classification is not None:
100
+ bundle["howler"]["assessment"] = self.map_classification(classification)
101
+ logger.info("Successfully mapped Sentiel Incident %s", incident_id)
102
+ return bundle
103
+
104
+ except Exception as exc:
105
+ logger.error("Failed to map Sentiel Incident: %s", exc, exc_info=True)
106
+ return None
107
+
108
+ def get_customer_name(self, tid: str) -> str:
109
+ """Get customer name from tenant ID, return default if not found.
110
+
111
+ Args:
112
+ tid (str): Tenant ID.
113
+ Returns:
114
+ str: Customer name or default.
115
+ """
116
+ return self.tid_mapping.get(tid, self.DEFAULT_CUSTOMER_NAME)
117
+
118
+ def map_sentinel_status_to_howler(self, sentinel_status: str) -> str:
119
+ """Map Sentiel Incident status to Howler status.
120
+
121
+ Args:
122
+ sentinel_status (str): Sentinel status string.
123
+ Returns:
124
+ str: Howler status string.
125
+ """
126
+ status_mapping: dict[str, str] = {
127
+ "new": "open",
128
+ "active": "in-progress",
129
+ "inProgress": "in-progress",
130
+ "resolved": "resolved",
131
+ "closed": "resolved",
132
+ }
133
+ return status_mapping.get(sentinel_status, "open")
134
+
135
+ def map_sentinel_user_to_howler(self, sentinel_user: Optional[str]) -> str:
136
+ """Map Sentinel user assignment to Howler format.
137
+
138
+ Args:
139
+ sentinel_user (Optional[str]): Sentinel user assignment.
140
+ Returns:
141
+ str: Howler assignment string.
142
+ """
143
+ if not sentinel_user or sentinel_user in ["null", "None"]:
144
+ return "unassigned"
145
+ return sentinel_user
146
+
147
+ def map_severity_to_score(self, severity: str) -> int:
148
+ """Map string severity to numeric score.
149
+
150
+ Args:
151
+ severity (str): Severity string.
152
+ Returns:
153
+ int: Numeric score.
154
+ """
155
+ severity_mapping: dict[str, int] = {"low": 25, "medium": 50, "high": 75, "critical": 100}
156
+ return severity_mapping.get(severity.lower() if severity else "medium", 50)
157
+
158
+ def map_classification(self, classification: str) -> str:
159
+ """Map Sentinel classification to Howler assessment.
160
+
161
+ Args:
162
+ classification (str): Sentinel classification string.
163
+ Returns:
164
+ str: Howler assessment string.
165
+ """
166
+ classification_mapping: dict[str, str] = {
167
+ "unknown": "ambiguous",
168
+ "truePositive": "compromise",
169
+ "falsePositive": "false-positive",
170
+ "informationalExpectedActivity": "legitimate",
171
+ "benignPositive": "legitimate",
172
+ }
173
+ return classification_mapping.get(classification, "")
174
+
175
+ # --- Private helper methods ---
176
+
177
+ def _map_graph_host_link(self, graph_alert: dict[str, Any], howler_hit: dict[str, Any]) -> None:
178
+ """Map Graph host link from Graph alert to Howler hit.
179
+
180
+ Args:
181
+ graph_alert (dict[str, Any]): Graph alert data.
182
+ howler_hit (dict[str, Any]): Howler hit/bundle to update.
183
+ """
184
+ link: dict[str, str] = {
185
+ "icon": "https://security.microsoft.com/favicon.ico",
186
+ "title": "Open in Microsoft Sentinel portal",
187
+ "href": graph_alert.get("incidentWebUrl", ""),
188
+ }
189
+ if graph_alert.get("incidentWebUrl"):
190
+ howler_hit["howler"]["links"] = howler_hit["howler"].get("links", [])
191
+ howler_hit["howler"]["links"].append(link)
192
+
193
+ def _map_timestamps(self, graph_alert: dict[str, Any], howler_hit: dict[str, Any]) -> None:
194
+ """Map timestamps from Graph alert to Howler hit.
195
+
196
+ Args:
197
+ graph_alert (dict[str, Any]): Graph alert data.
198
+ howler_hit (dict[str, Any]): Howler hit/bundle to update.
199
+ """
200
+ for time_field in [
201
+ "createdDateTime",
202
+ "lastUpdateDateTime",
203
+ "firstActivityDateTime",
204
+ "lastActivityDateTime",
205
+ ]:
206
+ timestamp: Optional[str] = graph_alert.get(time_field)
207
+ if timestamp:
208
+ try:
209
+ dt_obj = parser.isoparse(timestamp)
210
+ if time_field == "createdDateTime":
211
+ howler_hit["event"]["created"] = dt_obj.isoformat()
212
+ elif time_field == "firstActivityDateTime":
213
+ howler_hit["event"]["start"] = dt_obj.isoformat()
214
+ elif time_field == "lastActivityDateTime":
215
+ howler_hit["event"]["end"] = dt_obj.isoformat()
216
+ except Exception as exc:
217
+ logger.warning("Invalid timestamp format for %s: %s (%s)", time_field, timestamp, exc)
218
+
219
+ def _build_labels(self, custom_tags: list[str], system_tags: list[str]) -> list[str]:
220
+ """Build combined labels from custom and system tags.
221
+
222
+ Args:
223
+ custom_tags (list[str]): Custom tags.
224
+ system_tags (list[str]): System tags.
225
+ Returns:
226
+ list[str]: Combined label list.
227
+ """
228
+ labels: list[str] = []
229
+ if custom_tags:
230
+ labels.extend(custom_tags)
231
+ if system_tags:
232
+ labels.extend(["system_%s" % tag for tag in system_tags])
233
+ labels.append("sentinel_incident")
234
+ return labels