howler-sentinel-plugin 0.2.0.dev373__tar.gz → 0.2.0.dev418__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.
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/PKG-INFO +1 -1
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/pyproject.toml +3 -3
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/actions/update_defender_xdr_alert.py +5 -5
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/actions/update_defender_xdr_incident.py +5 -5
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/routes/ingest.py +98 -55
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/LICENSE +0 -0
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/README.md +0 -0
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/__init__.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/actions/azure_emit_hash.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/actions/send_to_sentinel.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/config.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/manifest.yml +0 -0
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/mapping/sentinel_incident.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/mapping/xdr_alert.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/mapping/xdr_alert_evidence.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/odm/__init__.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/odm/hit.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/odm/models/sentinel.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/routes/__init__.py +0 -0
- {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/utils/tenant_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "howler-sentinel-plugin"
|
|
3
|
-
version = "0.2.0.
|
|
3
|
+
version = "0.2.0.dev418"
|
|
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" }
|
|
@@ -131,14 +131,14 @@ packages = [{ include = "sentinel" }]
|
|
|
131
131
|
|
|
132
132
|
|
|
133
133
|
[tool.poetry.group.dev.dependencies]
|
|
134
|
-
ruff = "^0.15.
|
|
134
|
+
ruff = "^0.15.20"
|
|
135
135
|
mypy = "^1.20.2"
|
|
136
136
|
pre-commit = "^4.6.0"
|
|
137
137
|
howler-api = { path = "../../api" }
|
|
138
138
|
pytest = "^9.1.1"
|
|
139
139
|
mock = "^5.2.0"
|
|
140
140
|
pytest-cov = "^6.3.0"
|
|
141
|
-
coverage = { extras = ["toml"], version = "^7.
|
|
141
|
+
coverage = { extras = ["toml"], version = "^7.15.0" }
|
|
142
142
|
diff-cover = "^10.3.0"
|
|
143
143
|
python-dotenv = "^1.2.2"
|
|
144
144
|
|
|
@@ -4,7 +4,7 @@ from howler.common.loader import datastore
|
|
|
4
4
|
from howler.common.logging import get_logger
|
|
5
5
|
from howler.odm.models.action import VALID_TRIGGERS
|
|
6
6
|
from howler.odm.models.hit import Hit
|
|
7
|
-
from howler.odm.models.howler_data import Assessment,
|
|
7
|
+
from howler.odm.models.howler_data import Assessment, Status
|
|
8
8
|
|
|
9
9
|
from sentinel.utils.tenant_utils import get_token
|
|
10
10
|
|
|
@@ -15,10 +15,10 @@ OPERATION_ID = "update_defender_xdr_alert"
|
|
|
15
15
|
properties_map = {
|
|
16
16
|
"graph": {
|
|
17
17
|
"status": {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
Status.OPEN: "new",
|
|
19
|
+
Status.IN_PROGRESS: "inProgress",
|
|
20
|
+
Status.ON_HOLD: "inProgress",
|
|
21
|
+
Status.RESOLVED: "resolved",
|
|
22
22
|
},
|
|
23
23
|
"classification": {
|
|
24
24
|
Assessment.AMBIGUOUS: "unknown",
|
|
@@ -4,7 +4,7 @@ from howler.common.loader import datastore
|
|
|
4
4
|
from howler.common.logging import get_logger
|
|
5
5
|
from howler.odm.models.action import VALID_TRIGGERS
|
|
6
6
|
from howler.odm.models.hit import Hit
|
|
7
|
-
from howler.odm.models.howler_data import Assessment,
|
|
7
|
+
from howler.odm.models.howler_data import Assessment, Status
|
|
8
8
|
|
|
9
9
|
from sentinel.utils.tenant_utils import get_token
|
|
10
10
|
|
|
@@ -15,10 +15,10 @@ OPERATION_ID = "update_defender_xdr_incident"
|
|
|
15
15
|
properties_map = {
|
|
16
16
|
"graph": {
|
|
17
17
|
"status": {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
Status.OPEN: "active",
|
|
19
|
+
Status.IN_PROGRESS: "inProgress",
|
|
20
|
+
Status.ON_HOLD: "inProgress",
|
|
21
|
+
Status.RESOLVED: "resolved",
|
|
22
22
|
},
|
|
23
23
|
"classification": {
|
|
24
24
|
Assessment.AMBIGUOUS: "unknown",
|
|
@@ -7,7 +7,9 @@ from howler.common.exceptions import HowlerException
|
|
|
7
7
|
from howler.common.loader import datastore
|
|
8
8
|
from howler.common.logging import get_logger
|
|
9
9
|
from howler.common.swagger import generate_swagger_docs
|
|
10
|
-
from howler.
|
|
10
|
+
from howler.config import CLASSIFICATION
|
|
11
|
+
from howler.odm.models.user import User
|
|
12
|
+
from howler.services import action_service, analytic_service, case_service, hit_service
|
|
11
13
|
|
|
12
14
|
from sentinel.mapping.sentinel_incident import SentinelIncident
|
|
13
15
|
from sentinel.mapping.xdr_alert import XDRAlert
|
|
@@ -18,6 +20,16 @@ sentinel_api._doc = "Ingest Microsoft Sentinel XDR incidents into Howler" # typ
|
|
|
18
20
|
|
|
19
21
|
logger = get_logger(__file__)
|
|
20
22
|
|
|
23
|
+
SYSTEM_USER = User(
|
|
24
|
+
{
|
|
25
|
+
"uname": "system",
|
|
26
|
+
"name": "system",
|
|
27
|
+
"type": ["admin", "user"],
|
|
28
|
+
"classification": CLASSIFICATION.RESTRICTED,
|
|
29
|
+
"password": "__NO_PASSWORD__",
|
|
30
|
+
}
|
|
31
|
+
)
|
|
32
|
+
|
|
21
33
|
|
|
22
34
|
@generate_swagger_docs()
|
|
23
35
|
@sentinel_api.route("/ingest", methods=["POST"])
|
|
@@ -115,33 +127,50 @@ def _update_existing_incident(
|
|
|
115
127
|
) -> tuple[dict[str, Any], int]:
|
|
116
128
|
"""Update an existing incident and its underlying alerts in Howler.
|
|
117
129
|
|
|
130
|
+
Finds the associated case via ``howler.related`` on the root hit, then
|
|
131
|
+
updates the status on the root hit and all child hits referenced by the case.
|
|
132
|
+
|
|
118
133
|
Args:
|
|
119
|
-
existing_bundle: The existing Howler
|
|
134
|
+
existing_bundle: The existing Howler root hit object.
|
|
120
135
|
xdr_incident: The incoming XDR incident data.
|
|
121
136
|
incident_mapper: The incident mapper instance.
|
|
122
137
|
|
|
123
138
|
Returns:
|
|
124
139
|
Tuple containing response dictionary and HTTP status code.
|
|
125
140
|
"""
|
|
141
|
+
ds = datastore()
|
|
126
142
|
new_status = xdr_incident.get("status")
|
|
143
|
+
|
|
144
|
+
# Find child hit IDs via the case items
|
|
145
|
+
child_hit_ids: list[str] = []
|
|
146
|
+
for related_id in getattr(existing_bundle.howler, "related", []):
|
|
147
|
+
case = ds.case.get(related_id)
|
|
148
|
+
if case is not None:
|
|
149
|
+
child_hit_ids = [
|
|
150
|
+
item.value for item in case.items if item.type == "hit" and item.value != existing_bundle.howler.id
|
|
151
|
+
]
|
|
152
|
+
break
|
|
153
|
+
|
|
127
154
|
if new_status:
|
|
128
155
|
existing_bundle.howler.status = incident_mapper.map_sentinel_status_to_howler(new_status)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
child_hit
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
156
|
+
ds.hit.save(existing_bundle.howler.id, existing_bundle)
|
|
157
|
+
|
|
158
|
+
for child_id in child_hit_ids:
|
|
159
|
+
child_hit = ds.hit.get(child_id, as_obj=True)
|
|
160
|
+
if child_hit:
|
|
161
|
+
child_hit.howler.status = incident_mapper.map_sentinel_status_to_howler(new_status)
|
|
162
|
+
ds.hit.save(child_id, child_hit)
|
|
163
|
+
|
|
164
|
+
ds.hit.commit()
|
|
165
|
+
logger.info("Updated status for existing incident %s and its child hits", existing_bundle.howler.id)
|
|
137
166
|
return ok(
|
|
138
167
|
{
|
|
139
168
|
"success": True,
|
|
140
169
|
"bundle_hit_id": existing_bundle.howler.id,
|
|
141
170
|
"bundle_id": existing_bundle.sentinel.id if hasattr(existing_bundle, "sentinel") else None,
|
|
142
|
-
"individual_hit_ids":
|
|
143
|
-
"total_hits_updated": 1 + len(
|
|
144
|
-
"bundle_size": len(
|
|
171
|
+
"individual_hit_ids": child_hit_ids,
|
|
172
|
+
"total_hits_updated": 1 + len(child_hit_ids),
|
|
173
|
+
"bundle_size": len(child_hit_ids),
|
|
145
174
|
"organization": getattr(existing_bundle, "organization", {}).get("name", ""),
|
|
146
175
|
"updated": True,
|
|
147
176
|
}
|
|
@@ -169,7 +198,7 @@ def _create_alert_hits(alerts: list[dict[str, Any]], tenant_id: str, alert_mappe
|
|
|
169
198
|
alert_hit_odm.event.id = alert_hit_odm.howler.id
|
|
170
199
|
logger.info("Creating individual alert hit %s with ID %s", i, alert_hit_odm.howler.id)
|
|
171
200
|
hit_service.create_hit(alert_hit_odm.howler.id, alert_hit_odm, user="system")
|
|
172
|
-
analytic_service.save_from_hits(alert_hit_odm,
|
|
201
|
+
analytic_service.save_from_hits(alert_hit_odm, SYSTEM_USER)
|
|
173
202
|
child_hit_ids.append(alert_hit_odm.howler.id)
|
|
174
203
|
logger.debug("Successfully created alert hit %s: %s", i, alert_hit_odm.howler.id)
|
|
175
204
|
else:
|
|
@@ -180,33 +209,13 @@ def _create_alert_hits(alerts: list[dict[str, Any]], tenant_id: str, alert_mappe
|
|
|
180
209
|
return child_hit_ids
|
|
181
210
|
|
|
182
211
|
|
|
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
212
|
def _create_new_incident(
|
|
204
213
|
bundle_hit: dict[str, Any], xdr_incident: dict[str, Any], tenant_mapping: dict[str, str]
|
|
205
214
|
) -> tuple[dict[str, Any], int]:
|
|
206
|
-
"""Create a new incident
|
|
215
|
+
"""Create a new incident as a root hit plus a case with child alert items.
|
|
207
216
|
|
|
208
217
|
Args:
|
|
209
|
-
bundle_hit: The mapped Howler
|
|
218
|
+
bundle_hit: The mapped Howler hit data for the root incident hit.
|
|
210
219
|
xdr_incident: The incoming XDR incident data.
|
|
211
220
|
tenant_mapping: The tenant mapping dictionary.
|
|
212
221
|
|
|
@@ -218,32 +227,66 @@ def _create_new_incident(
|
|
|
218
227
|
alert_mapper = XDRAlert(tid_mapping=tenant_mapping)
|
|
219
228
|
child_hit_ids = _create_alert_hits(alerts, tenant_id, alert_mapper)
|
|
220
229
|
try:
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
230
|
+
# Strip legacy bundle fields so convert_hit doesn't choke
|
|
231
|
+
if isinstance(bundle_hit.get("howler"), dict):
|
|
232
|
+
bundle_hit["howler"].pop("is_bundle", None)
|
|
233
|
+
bundle_hit["howler"].pop("hits", None)
|
|
234
|
+
bundle_hit["howler"].pop("bundle_size", None)
|
|
235
|
+
bundle_hit["howler"].pop("bundles", None)
|
|
236
|
+
|
|
237
|
+
bundle_odm, _ = hit_service.convert_hit(bundle_hit, unique=True, ignore_extra_values=True)
|
|
235
238
|
|
|
236
239
|
if bundle_odm.event is not None:
|
|
237
240
|
bundle_odm.event.id = bundle_odm.howler.id
|
|
238
241
|
|
|
239
242
|
logger.info("Creating incident hit with ID %s", bundle_odm.howler.id)
|
|
240
243
|
hit_service.create_hit(bundle_odm.howler.id, bundle_odm, user="system")
|
|
241
|
-
analytic_service.save_from_hits(bundle_odm,
|
|
244
|
+
analytic_service.save_from_hits(bundle_odm, SYSTEM_USER)
|
|
245
|
+
|
|
246
|
+
# Create a case linking root hit and children
|
|
242
247
|
if child_hit_ids:
|
|
243
|
-
|
|
248
|
+
analytic = bundle_odm.howler.analytic or "Sentinel"
|
|
249
|
+
detection = bundle_odm.howler.detection or "XDR Incident"
|
|
250
|
+
case = case_service.create_case(
|
|
251
|
+
{"title": f"{analytic} - {detection}", "summary": f"Sentinel incident {bundle_odm.howler.id}"},
|
|
252
|
+
user=SYSTEM_USER,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
case_service.append_case_item(
|
|
256
|
+
case.case_id,
|
|
257
|
+
item_type="hit",
|
|
258
|
+
item_value=bundle_odm.howler.id,
|
|
259
|
+
item_name=analytic,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# Re-fetch the case to get the latest saved state, then create the
|
|
263
|
+
# "hits" folder so children can be nested inside it.
|
|
264
|
+
case = datastore().case.get(case.case_id) or case
|
|
265
|
+
hits_folder = case_service.get_parent_from_path(case, "hits", create_if_missing=True)
|
|
266
|
+
datastore().case.save(case.case_id, case)
|
|
267
|
+
|
|
268
|
+
for child_id in child_hit_ids:
|
|
269
|
+
child_hit = hit_service.get_hit(child_id, as_odm=True)
|
|
270
|
+
if child_hit:
|
|
271
|
+
child_name = f"{child_hit.howler.analytic} ({child_id})"
|
|
272
|
+
try:
|
|
273
|
+
case_service.append_case_item(
|
|
274
|
+
case.case_id,
|
|
275
|
+
item_type="hit",
|
|
276
|
+
item_value=child_id,
|
|
277
|
+
item_parent=hits_folder.id if hits_folder else None,
|
|
278
|
+
item_name=child_name,
|
|
279
|
+
)
|
|
280
|
+
except Exception:
|
|
281
|
+
logger.exception("Failed to add child hit %s to case", child_id)
|
|
282
|
+
|
|
283
|
+
datastore().case.commit()
|
|
284
|
+
|
|
244
285
|
datastore().hit.commit()
|
|
286
|
+
|
|
245
287
|
if child_hit_ids:
|
|
246
|
-
action_service.bulk_execute_on_query(f"howler.id:{bundle_odm.howler.id}", user=
|
|
288
|
+
action_service.bulk_execute_on_query(f"howler.id:{bundle_odm.howler.id}", user=SYSTEM_USER)
|
|
289
|
+
|
|
247
290
|
logger.info("Successfully completed XDR incident ingestion")
|
|
248
291
|
response_body = {
|
|
249
292
|
"success": True,
|
|
@@ -256,5 +299,5 @@ def _create_new_incident(
|
|
|
256
299
|
}
|
|
257
300
|
return created(response_body)
|
|
258
301
|
except HowlerException as e:
|
|
259
|
-
logger.exception("Failed to create
|
|
260
|
-
return internal_error(err=f"Failed to create
|
|
302
|
+
logger.exception("Failed to create incident")
|
|
303
|
+
return internal_error(err=f"Failed to create incident: {str(e)}")
|
|
File without changes
|
|
File without changes
|
{howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/config.py
RENAMED
|
File without changes
|
{howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/manifest.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/odm/__init__.py
RENAMED
|
File without changes
|
{howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/odm/hit.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|