howler-sentinel-plugin 0.2.0.dev68__py3-none-any.whl → 0.2.0.dev92__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.
- {howler_sentinel_plugin-0.2.0.dev68.dist-info → howler_sentinel_plugin-0.2.0.dev92.dist-info}/METADATA +2 -1
- howler_sentinel_plugin-0.2.0.dev92.dist-info/RECORD +17 -0
- sentinel/actions/send_to_sentinel.py +103 -0
- sentinel/mapping/sentinel_incident.py +234 -0
- sentinel/mapping/xdr_alert.py +421 -0
- sentinel/mapping/xdr_alert_evidence.py +779 -0
- sentinel/routes/ingest.py +140 -11
- sentinel/utils/tenant_utils.py +38 -0
- howler_sentinel_plugin-0.2.0.dev68.dist-info/RECORD +0 -12
- {howler_sentinel_plugin-0.2.0.dev68.dist-info → howler_sentinel_plugin-0.2.0.dev92.dist-info}/LICENSE +0 -0
- {howler_sentinel_plugin-0.2.0.dev68.dist-info → howler_sentinel_plugin-0.2.0.dev92.dist-info}/WHEEL +0 -0
- {howler_sentinel_plugin-0.2.0.dev68.dist-info → howler_sentinel_plugin-0.2.0.dev92.dist-info}/entry_points.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: howler-sentinel-plugin
|
|
3
|
-
Version: 0.2.0.
|
|
3
|
+
Version: 0.2.0.dev92
|
|
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
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
sentinel/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
sentinel/actions/ingestion.py,sha256=_t7wrmozhoT_MmktDmNxYiXrA1Q0LCiDt0rTwHBkwbc,1669
|
|
3
|
+
sentinel/actions/send_to_sentinel.py,sha256=qxYPHqsA2jbEiQG1qJdnIVQkdvxlL1gyPXNmOUVYnmk,3280
|
|
4
|
+
sentinel/actions/synchronization.py,sha256=g5c34410zINWb4fSEzj94drnk5alRj_ju9xMrB39z0s,1818
|
|
5
|
+
sentinel/mapping/sentinel_incident.py,sha256=3QBnP6qFpJgE3pHvx5VvFnB3m2TVOoWxs8OysDlJVV8,9547
|
|
6
|
+
sentinel/mapping/xdr_alert.py,sha256=UPoqdZsjUXmJz0dCf_qMlh9Jr0D2HcSNOFvbg8lE4wY,18250
|
|
7
|
+
sentinel/mapping/xdr_alert_evidence.py,sha256=q622G4eZwFR3TCj418ZCpE83DGVicrWIQZo8Gkj_3FM,31323
|
|
8
|
+
sentinel/odm/hit.py,sha256=hAuO2ONMK3Ml8Xu6E7tHrmZ7M6HG5tT38RD9ZxwY254,666
|
|
9
|
+
sentinel/odm/models/sentinel.py,sha256=XT3XdT92uoCV5vmY9dT1jmcxRyuu9vp1gE8AwZdKBIc,337
|
|
10
|
+
sentinel/routes/__init__.py,sha256=JYmKRwIfEsiPos1XuMQ2mlGDbxk6TN_cVEM0K_RNze4,130
|
|
11
|
+
sentinel/routes/ingest.py,sha256=_9OdOw_9nBJseKIBnmHDLjnqZ_bDdM4wfLpLrek4-ak,7018
|
|
12
|
+
sentinel/utils/tenant_utils.py,sha256=W7kBtxYNhs3vcgMf78eIRqiTpDtqjzEI2H2d0papQ_Q,1224
|
|
13
|
+
howler_sentinel_plugin-0.2.0.dev92.dist-info/LICENSE,sha256=Wg2luVnxEkP2NSn11nh1US6W_nFFbICBAVTG9iG3t5M,1091
|
|
14
|
+
howler_sentinel_plugin-0.2.0.dev92.dist-info/METADATA,sha256=JxB76pRL1VAh9GWtDRhdo3ls9xtATvSwzpeU7vWubzU,748
|
|
15
|
+
howler_sentinel_plugin-0.2.0.dev92.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
|
|
16
|
+
howler_sentinel_plugin-0.2.0.dev92.dist-info/entry_points.txt,sha256=4IJyMY0V49s3Wp659ngN_7U8g66-czeKxI-_dNAFP5g,60
|
|
17
|
+
howler_sentinel_plugin-0.2.0.dev92.dist-info/RECORD,,
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from howler.common.exceptions import HowlerRuntimeError
|
|
3
|
+
from howler.common.loader import datastore
|
|
4
|
+
from howler.common.logging import get_logger
|
|
5
|
+
from howler.odm.models.action import VALID_TRIGGERS
|
|
6
|
+
from howler.odm.models.hit import Hit
|
|
7
|
+
|
|
8
|
+
from sentinel.utils.tenant_utils import get_token
|
|
9
|
+
|
|
10
|
+
logger = get_logger(__file__)
|
|
11
|
+
|
|
12
|
+
OPERATION_ID = "send_to_sentinel"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def execute(query: str, **kwargs):
|
|
16
|
+
"""Send hit to Microsoft Sentinel.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
query (str): The query on which to apply this automation.
|
|
20
|
+
"""
|
|
21
|
+
report = []
|
|
22
|
+
ds = datastore()
|
|
23
|
+
|
|
24
|
+
hits: list[Hit] = ds.hit.search(query, as_obj=True)["items"]
|
|
25
|
+
if not hits:
|
|
26
|
+
report.append(
|
|
27
|
+
{
|
|
28
|
+
"query": query,
|
|
29
|
+
"outcome": "error",
|
|
30
|
+
"title": "No hits returned by query",
|
|
31
|
+
"message": f"No hits returned by '{query}'",
|
|
32
|
+
}
|
|
33
|
+
)
|
|
34
|
+
return report
|
|
35
|
+
|
|
36
|
+
for hit in hits:
|
|
37
|
+
try:
|
|
38
|
+
token, credentials = get_token(hit.azure.tenant_id)
|
|
39
|
+
except HowlerRuntimeError as err:
|
|
40
|
+
logger.exception("Error on token fetching")
|
|
41
|
+
report.append(
|
|
42
|
+
{
|
|
43
|
+
"query": f"howler.id:{hit.howler.id}",
|
|
44
|
+
"outcome": "error",
|
|
45
|
+
"title": "Invalid Credentials",
|
|
46
|
+
"message": err.message,
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
uri = (
|
|
52
|
+
f"https://{credentials['dce']}.ingest.monitor.azure.com/dataCollectionRules/{credentials['dcr']}/"
|
|
53
|
+
+ f"streams/{credentials['table']}?api-version=2021-11-01-preview"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
payload = [
|
|
57
|
+
{
|
|
58
|
+
"TimeGenerated": hit.event.ingested.isoformat(),
|
|
59
|
+
"Title": hit.howler.analytic,
|
|
60
|
+
"RawData": {"Hit": hit.as_primitives(), "From": "Howler"},
|
|
61
|
+
}
|
|
62
|
+
]
|
|
63
|
+
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
|
64
|
+
|
|
65
|
+
response = requests.post(uri, headers=headers, json=payload, timeout=5.0)
|
|
66
|
+
if not response.ok:
|
|
67
|
+
report.append(
|
|
68
|
+
{
|
|
69
|
+
"query": f"howler.id:{hit.howler.id}",
|
|
70
|
+
"outcome": "error",
|
|
71
|
+
"title": "Azure Monitor API request failed",
|
|
72
|
+
"message": f"POST request to Azure Monitor failed with status code {response.status_code}.",
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
report.append(
|
|
78
|
+
{
|
|
79
|
+
"query": f"howler.id:{hit.howler.id}",
|
|
80
|
+
"outcome": "success",
|
|
81
|
+
"title": "Alert updated in Sentinel",
|
|
82
|
+
"message": "Howler has successfuly propagated changes to this alert to Sentinel.",
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return report
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def specification():
|
|
90
|
+
"Send to Sentinel action specification"
|
|
91
|
+
return {
|
|
92
|
+
"id": OPERATION_ID,
|
|
93
|
+
"title": "Send hit to Microsoft Sentinel",
|
|
94
|
+
"priority": 8,
|
|
95
|
+
"i18nKey": "Send hit to Microsoft Sentinel",
|
|
96
|
+
"description": {
|
|
97
|
+
"short": "Send hit to Microsoft Sentinel",
|
|
98
|
+
"long": execute.__doc__,
|
|
99
|
+
},
|
|
100
|
+
"roles": ["automation_basic"],
|
|
101
|
+
"steps": [{"args": {}, "options": {}, "validation": {}}],
|
|
102
|
+
"triggers": VALID_TRIGGERS,
|
|
103
|
+
}
|
|
@@ -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
|