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.
Files changed (20) hide show
  1. {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/PKG-INFO +1 -1
  2. {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/pyproject.toml +3 -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
  4. {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/actions/update_defender_xdr_incident.py +5 -5
  5. {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/routes/ingest.py +98 -55
  6. {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/LICENSE +0 -0
  7. {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/README.md +0 -0
  8. {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/__init__.py +0 -0
  9. {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/actions/azure_emit_hash.py +0 -0
  10. {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/actions/send_to_sentinel.py +0 -0
  11. {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/config.py +0 -0
  12. {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/manifest.yml +0 -0
  13. {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/mapping/sentinel_incident.py +0 -0
  14. {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/mapping/xdr_alert.py +0 -0
  15. {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/mapping/xdr_alert_evidence.py +0 -0
  16. {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/odm/__init__.py +0 -0
  17. {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/odm/hit.py +0 -0
  18. {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/odm/models/sentinel.py +0 -0
  19. {howler_sentinel_plugin-0.2.0.dev373 → howler_sentinel_plugin-0.2.0.dev418}/sentinel/routes/__init__.py +0 -0
  20. {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
  Metadata-Version: 2.4
2
2
  Name: howler-sentinel-plugin
3
- Version: 0.2.0.dev373
3
+ Version: 0.2.0.dev418
4
4
  Summary: A howler plugin for integration with Microsoft's Sentinel API
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "howler-sentinel-plugin"
3
- version = "0.2.0.dev373"
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.19"
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.14.3" }
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, HitStatus
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
- HitStatus.OPEN: "new",
19
- HitStatus.IN_PROGRESS: "inProgress",
20
- HitStatus.ON_HOLD: "inProgress",
21
- HitStatus.RESOLVED: "resolved",
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, HitStatus
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
- HitStatus.OPEN: "active",
19
- HitStatus.IN_PROGRESS: "inProgress",
20
- HitStatus.ON_HOLD: "inProgress",
21
- HitStatus.RESOLVED: "resolved",
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.services import action_service, analytic_service, hit_service
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 bundle object.
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
- 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)
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": 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", [])),
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, {"uname": "system"})
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 bundle and its underlying alerts in Howler.
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 bundle data.
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
- 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
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, {"uname": "system"})
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
- _link_child_hits_to_bundle(bundle_odm, child_hit_ids)
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={"uname": "system"})
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 bundle")
260
- return internal_error(err=f"Failed to create bundle: {str(e)}")
302
+ logger.exception("Failed to create incident")
303
+ return internal_error(err=f"Failed to create incident: {str(e)}")