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.
File without changes
sentinel/odm/hit.py ADDED
@@ -0,0 +1,33 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ import howler.odm as odm
4
+ from howler.common.logging import get_logger
5
+ from howler.odm.models.azure import Azure
6
+
7
+ from sentinel.odm.models.sentinel import Sentinel
8
+
9
+ if TYPE_CHECKING:
10
+ from howler.odm.models.hit import Hit
11
+
12
+
13
+ logger = get_logger(__file__)
14
+
15
+
16
+ def modify_odm(target):
17
+ "Add additional internal fields to the ODM"
18
+ target.add_namespace(
19
+ "sentinel",
20
+ odm.Optional(odm.Compound(Sentinel, description="Sentinel metadata associated with this alert")),
21
+ )
22
+
23
+
24
+ def generate(hit: "Hit") -> "Hit": # pragma: no cover
25
+ "Add cccs-specific changes to hits on generation"
26
+ hit.sentinel = Sentinel({"id": "example-sentinel-id"})
27
+
28
+ if not hit.azure:
29
+ hit.azure = Azure({"tenant_id": "example-tenant-id"})
30
+ else:
31
+ hit.azure.tenant_id = "example-tenant-id"
32
+
33
+ return ["sentinel"], hit
@@ -0,0 +1,15 @@
1
+ from typing import Optional
2
+
3
+ from howler import odm
4
+
5
+
6
+ @odm.model(
7
+ index=True,
8
+ store=True,
9
+ description="The Sentinel fields contain any data relating to Sentinel.",
10
+ )
11
+ class Sentinel(odm.Model):
12
+ id: Optional[str] = odm.Keyword(
13
+ optional=True,
14
+ description="The sentinel alert url for a staged alert.",
15
+ )
File without changes
@@ -0,0 +1,260 @@
1
+ import re
2
+ from typing import Any
3
+
4
+ from flask import request
5
+ from howler.api import bad_request, created, internal_error, make_subapi_blueprint, ok, unauthorized
6
+ from howler.common.exceptions import HowlerException
7
+ from howler.common.loader import datastore
8
+ from howler.common.logging import get_logger
9
+ from howler.common.swagger import generate_swagger_docs
10
+ from howler.services import action_service, analytic_service, hit_service
11
+
12
+ from sentinel.mapping.sentinel_incident import SentinelIncident
13
+ from sentinel.mapping.xdr_alert import XDRAlert
14
+
15
+ SUB_API = "sentinel"
16
+ sentinel_api = make_subapi_blueprint(SUB_API, api_version=1)
17
+ sentinel_api._doc = "Ingest Microsoft Sentinel XDR incidents into Howler"
18
+
19
+ logger = get_logger(__file__)
20
+
21
+
22
+ @generate_swagger_docs()
23
+ @sentinel_api.route("/ingest", methods=["POST"])
24
+ def ingest_xdr_incident(**kwargs) -> tuple[dict[str, Any], int]: # noqa C901
25
+ """Ingest a Microsoft Sentinel XDR incident into Howler.
26
+
27
+ Variables:
28
+ None
29
+
30
+ Arguments:
31
+ None
32
+
33
+ Data Block:
34
+ {
35
+ ...Sentinel XDR incident JSON...
36
+ }
37
+
38
+ Headers:
39
+ Authorization: API in the format "Basic <key>"
40
+
41
+ Result Example (201 Created):
42
+ {
43
+ "success": True,
44
+ "bundle_hit_id": "howler-bundle-id",
45
+ "bundle_id": "sentinel-incident-id",
46
+ "individual_hit_ids": ["alert-hit-id-1", "alert-hit-id-2"],
47
+ "total_hits_created": 3,
48
+ "bundle_size": 2,
49
+ "organization": "Acme Corporation"
50
+ }
51
+
52
+ Result Example (200 OK, update):
53
+ {
54
+ "success": True,
55
+ "bundle_hit_id": "howler-bundle-id",
56
+ "bundle_id": "sentinel-incident-id",
57
+ "individual_hit_ids": ["alert-hit-id-1", "alert-hit-id-2"],
58
+ "total_hits_updated": 3,
59
+ "bundle_size": 2,
60
+ "organization": "Acme Corporation",
61
+ "updated": True
62
+ }
63
+
64
+ Error Codes:
65
+ 400 - Bad request (e.g., missing JSON)
66
+ 401 - Unauthorized (invalid API key)
67
+ 500 - Internal server error
68
+
69
+ Description:
70
+ Receives a Microsoft Sentinel XDR incident as JSON, maps it to Howler format, and creates or updates a bundle
71
+ and its underlying alerts in Howler. Returns details about the created or updated bundle and alerts.
72
+ """
73
+ from sentinel.config import config
74
+
75
+ # API Key authentication
76
+ apikey = request.headers.get("Authorization", "Basic ", type=str).split(" ")[1]
77
+
78
+ link_key = config.auth.link_key
79
+
80
+ if not apikey or apikey != link_key:
81
+ return unauthorized(err="API Key does not match expected value.")
82
+
83
+ logger.info("Received authorization header with value %s", re.sub(r"^(.{3}).+(.{3})$", r"\1...\2", apikey))
84
+
85
+ xdr_incident = request.json
86
+ if not xdr_incident:
87
+ return bad_request(err="No JSON data provided in request body")
88
+
89
+ try:
90
+ # TODO needs to be replaced with actual tenant mapping logic
91
+ tenant_mapping = {"020cd98f-1002-45b7-90ff-69fc68bdd027": "Acme Corporation"}
92
+ incident_mapper = SentinelIncident(tid_mapping=tenant_mapping)
93
+ bundle_hit = incident_mapper.map_incident_to_bundle(xdr_incident)
94
+ if bundle_hit is None:
95
+ return internal_error(err="Failed to map XDR incident to Howler bundle format")
96
+
97
+ sentinel_id = xdr_incident.get("id")
98
+ if sentinel_id:
99
+ existing_bundles = datastore().hit.search(f"sentinel.id:{sentinel_id}", as_obj=True)["items"]
100
+ if existing_bundles:
101
+ return _update_existing_incident(existing_bundles[0], xdr_incident, incident_mapper)
102
+
103
+ return _create_new_incident(bundle_hit, xdr_incident, tenant_mapping)
104
+
105
+ except HowlerException as e:
106
+ logger.exception("Failed to process XDR incident")
107
+ return internal_error(err=f"Failed to process XDR incident: {str(e)}")
108
+ except Exception as e:
109
+ logger.exception("Unexpected error during XDR incident ingestion")
110
+ return internal_error(err=f"Internal error occurred during ingestion: {str(e)}")
111
+
112
+
113
+ def _update_existing_incident(
114
+ existing_bundle: Any, xdr_incident: dict[str, Any], incident_mapper: SentinelIncident
115
+ ) -> tuple[dict[str, Any], int]:
116
+ """Update an existing incident and its underlying alerts in Howler.
117
+
118
+ Args:
119
+ existing_bundle: The existing Howler bundle object.
120
+ xdr_incident: The incoming XDR incident data.
121
+ incident_mapper: The incident mapper instance.
122
+
123
+ Returns:
124
+ Tuple containing response dictionary and HTTP status code.
125
+ """
126
+ new_status = xdr_incident.get("status")
127
+ if new_status:
128
+ existing_bundle.howler.status = incident_mapper.map_sentinel_status_to_howler(new_status)
129
+ datastore().hit.save(existing_bundle.howler.id, existing_bundle)
130
+ for child_id in getattr(existing_bundle.howler, "hits", []):
131
+ child_hit = datastore().hit.get(child_id, as_obj=True)
132
+ if child_hit:
133
+ child_hit.howler.status = incident_mapper.map_sentinel_status_to_howler(new_status)
134
+ datastore().hit.save(child_id, child_hit)
135
+ datastore().hit.commit()
136
+ logger.info("Updated status for existing bundle %s and its child hits", existing_bundle.howler.id)
137
+ return ok(
138
+ {
139
+ "success": True,
140
+ "bundle_hit_id": existing_bundle.howler.id,
141
+ "bundle_id": existing_bundle.sentinel.id if hasattr(existing_bundle, "sentinel") else None,
142
+ "individual_hit_ids": getattr(existing_bundle.howler, "hits", []),
143
+ "total_hits_updated": 1 + len(getattr(existing_bundle.howler, "hits", [])),
144
+ "bundle_size": len(getattr(existing_bundle.howler, "hits", [])),
145
+ "organization": getattr(existing_bundle, "organization", {}).get("name", ""),
146
+ "updated": True,
147
+ }
148
+ )
149
+
150
+
151
+ def _create_alert_hits(alerts: list[dict[str, Any]], tenant_id: str, alert_mapper: XDRAlert) -> list[str]:
152
+ """Create alert hits from the provided alerts and return their IDs.
153
+
154
+ Args:
155
+ alerts: List of alert dictionaries.
156
+ tenant_id: The tenant ID string.
157
+ alert_mapper: The alert mapper instance.
158
+
159
+ Returns:
160
+ List of created alert hit IDs.
161
+ """
162
+ child_hit_ids = []
163
+ for i, alert in enumerate(alerts):
164
+ try:
165
+ mapped_hit = alert_mapper.map_alert(alert, tenant_id)
166
+ if mapped_hit:
167
+ alert_hit_odm, _ = hit_service.convert_hit(mapped_hit, unique=True, ignore_extra_values=True)
168
+ if alert_hit_odm.event is not None:
169
+ alert_hit_odm.event.id = alert_hit_odm.howler.id
170
+ logger.info("Creating individual alert hit %s with ID %s", i, alert_hit_odm.howler.id)
171
+ hit_service.create_hit(alert_hit_odm.howler.id, alert_hit_odm, user="system")
172
+ analytic_service.save_from_hit(alert_hit_odm, {"uname": "system"})
173
+ child_hit_ids.append(alert_hit_odm.howler.id)
174
+ logger.debug("Successfully created alert hit %s: %s", i, alert_hit_odm.howler.id)
175
+ else:
176
+ logger.warning("Alert mapper returned None for alert %s: %s", i, alert.get("id", "unknown"))
177
+ except Exception:
178
+ logger.exception("Failed to create individual alert hit %s", i)
179
+ continue
180
+ return child_hit_ids
181
+
182
+
183
+ def _link_child_hits_to_bundle(bundle_odm: Any, child_hit_ids: list[str]) -> None:
184
+ """Link child hits to the bundle and update their bundle references.
185
+
186
+ Args:
187
+ bundle_odm: The bundle ODM object.
188
+ child_hit_ids: List of child hit IDs to link.
189
+ """
190
+ for hit_id in bundle_odm.howler.hits:
191
+ child_hit = hit_service.get_hit(hit_id, as_odm=True)
192
+
193
+ if child_hit.howler.is_bundle:
194
+ logger.warning("Child hit %s is a bundle - skipping bundle assignment", child_hit.howler.id)
195
+ continue
196
+
197
+ new_bundle_list = child_hit.howler.get("bundles", [])
198
+ new_bundle_list.append(bundle_odm.howler.id)
199
+ child_hit.howler.bundles = new_bundle_list
200
+ datastore().hit.save(child_hit.howler.id, child_hit)
201
+
202
+
203
+ def _create_new_incident(
204
+ bundle_hit: dict[str, Any], xdr_incident: dict[str, Any], tenant_mapping: dict[str, str]
205
+ ) -> tuple[dict[str, Any], int]:
206
+ """Create a new incident bundle and its underlying alerts in Howler.
207
+
208
+ Args:
209
+ bundle_hit: The mapped Howler bundle data.
210
+ xdr_incident: The incoming XDR incident data.
211
+ tenant_mapping: The tenant mapping dictionary.
212
+
213
+ Returns:
214
+ Tuple containing response dictionary and HTTP status code.
215
+ """
216
+ alerts = xdr_incident.get("alerts", [])
217
+ tenant_id = xdr_incident.get("tenantId", "")
218
+ alert_mapper = XDRAlert(tid_mapping=tenant_mapping)
219
+ child_hit_ids = _create_alert_hits(alerts, tenant_id, alert_mapper)
220
+ try:
221
+ bundle_odm, _ = hit_service.convert_hit(bundle_hit, unique=True)
222
+ # If there are no alerts, do not treat as bundle
223
+ if child_hit_ids:
224
+ bundle_odm.howler.is_bundle = True
225
+ if not hasattr(bundle_odm.howler, "hits") or not isinstance(bundle_odm.howler.hits, list):
226
+ bundle_odm.howler.hits = []
227
+ for hit_id in child_hit_ids:
228
+ if hit_id not in bundle_odm.howler.hits:
229
+ bundle_odm.howler.hits.append(hit_id)
230
+ bundle_odm.howler.bundle_size = len(bundle_odm.howler.hits)
231
+ else:
232
+ bundle_odm.howler.is_bundle = False
233
+ bundle_odm.howler.hits = []
234
+ bundle_odm.howler.bundle_size = 0
235
+
236
+ if bundle_odm.event is not None:
237
+ bundle_odm.event.id = bundle_odm.howler.id
238
+
239
+ logger.info("Creating incident hit with ID %s", bundle_odm.howler.id)
240
+ hit_service.create_hit(bundle_odm.howler.id, bundle_odm, user="system")
241
+ analytic_service.save_from_hit(bundle_odm, {"uname": "system"})
242
+ if child_hit_ids:
243
+ _link_child_hits_to_bundle(bundle_odm, child_hit_ids)
244
+ datastore().hit.commit()
245
+ if child_hit_ids:
246
+ action_service.bulk_execute_on_query(f"howler.id:{bundle_odm.howler.id}", user={"uname": "system"})
247
+ logger.info("Successfully completed XDR incident ingestion")
248
+ response_body = {
249
+ "success": True,
250
+ "bundle_hit_id": bundle_odm.howler.id,
251
+ "bundle_id": bundle_hit["howler"].get("xdr.incident.id"),
252
+ "individual_hit_ids": child_hit_ids,
253
+ "total_hits_created": len(child_hit_ids) + 1,
254
+ "bundle_size": len(child_hit_ids),
255
+ "organization": bundle_hit["organization"]["name"],
256
+ }
257
+ return created(response_body)
258
+ except HowlerException as e:
259
+ logger.exception("Failed to create bundle")
260
+ return internal_error(err=f"Failed to create bundle: {str(e)}")
@@ -0,0 +1,56 @@
1
+ import sys
2
+ from typing import Optional
3
+
4
+ import requests
5
+ from howler.common.exceptions import HowlerRuntimeError
6
+ from howler.common.logging import get_logger
7
+ from howler.config import cache
8
+
9
+ logger = get_logger(__file__)
10
+
11
+
12
+ def skip_cache(*args):
13
+ "Function to skip cache in testing mode"
14
+ return "pytest" in sys.modules
15
+
16
+
17
+ @cache.memoize(15 * 60, unless=skip_cache)
18
+ def get_token(tenant_id: str, scope: str) -> Optional[str]:
19
+ """Get a sentinel token based on the current howler token"""
20
+ from sentinel.config import config
21
+
22
+ token = None
23
+
24
+ if config.auth.client_credentials:
25
+ logger.info(
26
+ "Using client_credentials flow for client id %s with scope %s",
27
+ config.auth.client_credentials.client_id,
28
+ scope,
29
+ )
30
+
31
+ token_request_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"
32
+ response = requests.post(
33
+ token_request_url,
34
+ data={
35
+ "grant_type": "client_credentials",
36
+ "client_id": config.auth.client_credentials.client_id,
37
+ "client_secret": config.auth.client_credentials.client_secret,
38
+ "scope": scope,
39
+ },
40
+ timeout=5.0,
41
+ )
42
+
43
+ if not response.ok:
44
+ raise HowlerRuntimeError(
45
+ "Authentication to Azure Monitor API using client_credentials flow failed with status code"
46
+ f" {response.status_code}. Response:\n{response.text}"
47
+ )
48
+
49
+ token = response.json()["access_token"]
50
+ elif config.auth.custom_auth:
51
+ token = config.auth.custom_auth(tenant_id, scope)
52
+
53
+ if not token:
54
+ logger.warning("No access token received")
55
+
56
+ return token