detect-droid 0.3.3__tar.gz → 0.3.4__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 (55) hide show
  1. {detect_droid-0.3.3/src/detect_droid.egg-info → detect_droid-0.3.4}/PKG-INFO +1 -1
  2. {detect_droid-0.3.3 → detect_droid-0.3.4}/setup.cfg +1 -1
  3. {detect_droid-0.3.3 → detect_droid-0.3.4/src/detect_droid.egg-info}/PKG-INFO +1 -1
  4. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/detect_droid.egg-info/SOURCES.txt +3 -0
  5. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/convert.py +2 -1
  6. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/integrity.py +14 -7
  7. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/platforms/common.py +22 -0
  8. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/platforms/ms_xdr.py +37 -43
  9. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/platforms/sentinel.py +8 -8
  10. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/search.py +16 -8
  11. detect_droid-0.3.4/tests/test_export_error_propagation.py +142 -0
  12. detect_droid-0.3.4/tests/test_integrity.py +316 -0
  13. detect_droid-0.3.4/tests/test_search_logging.py +424 -0
  14. {detect_droid-0.3.3 → detect_droid-0.3.4}/LICENSE +0 -0
  15. {detect_droid-0.3.3 → detect_droid-0.3.4}/NOTICE +0 -0
  16. {detect_droid-0.3.3 → detect_droid-0.3.4}/README.md +0 -0
  17. {detect_droid-0.3.3 → detect_droid-0.3.4}/setup.py +0 -0
  18. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/detect_droid.egg-info/dependency_links.txt +0 -0
  19. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/detect_droid.egg-info/entry_points.txt +0 -0
  20. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/detect_droid.egg-info/requires.txt +0 -0
  21. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/detect_droid.egg-info/top_level.txt +0 -0
  22. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/__init__.py +0 -0
  23. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/__main__.py +0 -0
  24. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/abstracts.py +0 -0
  25. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/color.py +0 -0
  26. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/commands/__init__.py +0 -0
  27. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/commands/rules/__init__.py +0 -0
  28. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/commands/rules/convert.py +0 -0
  29. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/commands/rules/export.py +0 -0
  30. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/commands/rules/integrity.py +0 -0
  31. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/commands/rules/list_cmd.py +0 -0
  32. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/commands/rules/search.py +0 -0
  33. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/commands/rules/update.py +0 -0
  34. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/commands/rules/validate.py +0 -0
  35. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/commands/sources/__init__.py +0 -0
  36. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/commands/sources/update.py +0 -0
  37. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/config.py +0 -0
  38. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/export.py +0 -0
  39. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/list.py +0 -0
  40. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/platforms/__init__.py +0 -0
  41. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/platforms/elastic.py +0 -0
  42. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/platforms/registry.py +0 -0
  43. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/platforms/splunk.py +0 -0
  44. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/registry.py +0 -0
  45. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/rule_loader.py +0 -0
  46. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/sources/__init__.py +0 -0
  47. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/sources/sigmahq.py +0 -0
  48. {detect_droid-0.3.3 → detect_droid-0.3.4}/src/droid/validate.py +0 -0
  49. {detect_droid-0.3.3 → detect_droid-0.3.4}/tests/test_convert.py +0 -0
  50. {detect_droid-0.3.3 → detect_droid-0.3.4}/tests/test_export.py +0 -0
  51. {detect_droid-0.3.3 → detect_droid-0.3.4}/tests/test_json_logging.py +0 -0
  52. {detect_droid-0.3.3 → detect_droid-0.3.4}/tests/test_platforms_common.py +0 -0
  53. {detect_droid-0.3.3 → detect_droid-0.3.4}/tests/test_token_hook.py +0 -0
  54. {detect_droid-0.3.3 → detect_droid-0.3.4}/tests/test_validate.py +0 -0
  55. {detect_droid-0.3.3 → detect_droid-0.3.4}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detect-droid
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: Detection Rules Optimisation Integration Deployment
5
5
  Home-page: https://github.com/certeu/droid
6
6
  Author: cert-eu/mlc
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = detect-droid
3
- version = 0.3.3
3
+ version = 0.3.4
4
4
  author = cert-eu/mlc
5
5
  author_email = services@cert.europa.eu
6
6
  description = Detection Rules Optimisation Integration Deployment
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: detect-droid
3
- Version: 0.3.3
3
+ Version: 0.3.4
4
4
  Summary: Detection Rules Optimisation Integration Deployment
5
5
  Home-page: https://github.com/certeu/droid
6
6
  Author: cert-eu/mlc
@@ -44,8 +44,11 @@ src/droid/sources/__init__.py
44
44
  src/droid/sources/sigmahq.py
45
45
  tests/test_convert.py
46
46
  tests/test_export.py
47
+ tests/test_export_error_propagation.py
48
+ tests/test_integrity.py
47
49
  tests/test_json_logging.py
48
50
  tests/test_platforms_common.py
51
+ tests/test_search_logging.py
49
52
  tests/test_token_hook.py
50
53
  tests/test_validate.py
51
54
  tests/test_version.py
@@ -489,8 +489,9 @@ def convert_sigma(
489
489
  if parameters.export and parameters.search and rule_converted:
490
490
  try:
491
491
  error, search_warning = search_rule(parameters, rule_content, rule_converted, platform, rule_file, error, search_warning, logger_param)
492
- except:
492
+ except Exception as e:
493
493
  logger.error(f"Could not export the rule {rule_file} since the search ran into error.", extra={"rule_file": rule_file, "error": e, "rule_content": rule_content})
494
+ error = True
494
495
 
495
496
  if not error:
496
497
  error = False
@@ -29,7 +29,7 @@ def load_rule(rule_file):
29
29
  print("Error reading {0}".format(rule_file))
30
30
  return True
31
31
 
32
- def check_rule_removed(rule_content, rule_file, saved_search, logger, error):
32
+ def check_rule_removed(rule_content, rule_file, saved_search, logger, error, tolerate_deletion_delay=False):
33
33
  is_removed = rule_content.get("custom", {}).get("removed")
34
34
 
35
35
  if not saved_search:
@@ -40,6 +40,13 @@ def check_rule_removed(rule_content, rule_file, saved_search, logger, error):
40
40
  logger.error(f"Rule not found {rule_file}")
41
41
  error = True
42
42
  return error
43
+
44
+ if is_removed and tolerate_deletion_delay:
45
+ # The platform can take a while to actually drop the rule from its
46
+ # backend, comparing the content of a rule meant to be gone is pointless
47
+ logger.warning(f"The rule {rule_file} is marked as removed but is still present on the platform - skipping the integrity check")
48
+ return False
49
+
43
50
  return None
44
51
 
45
52
  def integrity_rule_splunk(rule_converted, rule_content, platform: SplunkPlatform, rule_file, parameters, logger, error):
@@ -264,9 +271,10 @@ def integrity_rule_ms_xdr_mssp(rule_converted, rule_content, platform: Microsoft
264
271
  logger.error(f"Couldn't check the integrity for the rule {rule_file} on tenant {tenant_id} from {group} - error {e}")
265
272
  return error
266
273
 
267
- error = check_rule_removed(rule_content, rule_file, saved_search, logger, error)
268
- if error is not None:
269
- error_occured = True
274
+ removed_check = check_rule_removed(rule_content, rule_file, saved_search, logger, error, tolerate_deletion_delay=True)
275
+ if removed_check is not None:
276
+ if removed_check:
277
+ error_occured = True
270
278
  continue
271
279
 
272
280
  error = integrity_rule_ms_xdr(customer_rule_converted, rule_content, platform, rule_file, parameters, logger, error, saved_search=saved_search)
@@ -274,8 +282,7 @@ def integrity_rule_ms_xdr_mssp(rule_converted, rule_content, platform: Microsoft
274
282
  if error:
275
283
  error_occured = True
276
284
 
277
- if error_occured:
278
- return error
285
+ return error_occured
279
286
 
280
287
  def integrity_rule_ms_xdr(rule_converted, rule_content, platform: MicrosoftXDRPlatform, rule_file, parameters, logger, error, saved_search=None):
281
288
  try:
@@ -285,7 +292,7 @@ def integrity_rule_ms_xdr(rule_converted, rule_content, platform: MicrosoftXDRPl
285
292
  logger.error(f"Couldn't check the integrity for the rule {rule_file} - error {e}")
286
293
  return error
287
294
 
288
- error = check_rule_removed(rule_content, rule_file, saved_search, logger, error)
295
+ error = check_rule_removed(rule_content, rule_file, saved_search, logger, error, tolerate_deletion_delay=True)
289
296
  if error is not None:
290
297
  return error
291
298
 
@@ -33,6 +33,28 @@ def get_token_hook_headers():
33
33
 
34
34
  return {}
35
35
 
36
+ def get_error_message(response):
37
+ """Extract the error message out of a platform API response
38
+
39
+ The response is the already decoded body. It may be None when the request
40
+ could not be performed at all, or may not carry the expected error
41
+ structure, hence the fallbacks.
42
+
43
+ Return: a str with the error message
44
+ """
45
+
46
+ if isinstance(response, dict):
47
+ error = response.get('error')
48
+ if isinstance(error, dict):
49
+ return error.get('message', str(error))
50
+ if error:
51
+ return str(error)
52
+
53
+ if response is None:
54
+ return "No response from the platform"
55
+
56
+ return str(response)
57
+
36
58
  def get_pipeline_group_match(rule_content: dict, fields: dict):
37
59
  """Retrieve the config group name based on a dict
38
60
 
@@ -10,10 +10,9 @@ import time
10
10
 
11
11
  from os import environ
12
12
  from datetime import datetime, timedelta
13
- from pprint import pprint
14
13
  from droid.abstracts import AbstractPlatform
15
14
  from droid.color import ColorLogger
16
- from droid.platforms.common import get_pipeline_group_match, get_token_hook_headers
15
+ from droid.platforms.common import get_error_message, get_pipeline_group_match, get_token_hook_headers
17
16
  from msal import ConfidentialClientApplication
18
17
  from azure.identity import DefaultAzureCredential
19
18
  from cryptography import x509
@@ -162,7 +161,7 @@ class MicrosoftXDRPlatform(AbstractPlatform):
162
161
  self.logger.info(f"Rule {rule_file} was successfully deleted from tenant {tenant_id}")
163
162
  else:
164
163
  self.logger.error(
165
- f"Could not delete {rule_file} from tenant {tenant_id} - error: {response.json()['error']['message']}",
164
+ f"Could not delete {rule_file} from tenant {tenant_id} - error: {get_error_message(response)}",
166
165
  extra={
167
166
  "rule_file": rule_file,
168
167
  "rule_converted": rule_converted,
@@ -184,10 +183,10 @@ class MicrosoftXDRPlatform(AbstractPlatform):
184
183
  )
185
184
  error = True
186
185
  if error:
187
- raise
186
+ raise Exception(f"Could not remove the rule {rule_file} from one or more tenants")
188
187
  else:
189
188
  self.logger.error("Export list not found. Please provide the list of designated customers")
190
- raise
189
+ raise Exception("Export list not found. Please provide the list of designated customers")
191
190
  else:
192
191
  existing_rule = self.get_rule(rule_content["id"])
193
192
  tenant_id = self._tenant_id
@@ -207,14 +206,14 @@ class MicrosoftXDRPlatform(AbstractPlatform):
207
206
  )
208
207
  else:
209
208
  self.logger.error(
210
- f"Could not delete {rule_file} - error: {response.json()['error']['message']}",
209
+ f"Could not delete {rule_file} - error: {get_error_message(response)}",
211
210
  extra={
212
211
  "rule_file": rule_file,
213
212
  "rule_converted": rule_converted,
214
213
  "rule_content": rule_content,
215
214
  },
216
215
  )
217
- raise
216
+ raise Exception(f"Could not delete {rule_file} - error: {get_error_message(response)}")
218
217
  else:
219
218
  self.logger.info(
220
219
  f"Rule {rule_file} was already removed",
@@ -473,12 +472,12 @@ class MicrosoftXDRPlatform(AbstractPlatform):
473
472
  )
474
473
  error = True
475
474
  if error:
476
- raise
475
+ raise Exception(f"Could not export the rule {rule_file} to one or more tenants")
477
476
  else:
478
477
  self.logger.error(
479
478
  "Export list not found. Please provide the list of designated customers"
480
479
  )
481
- raise
480
+ raise Exception("Export list not found. Please provide the list of designated customers")
482
481
  else:
483
482
  try:
484
483
  self.push_detection_rule(
@@ -574,28 +573,8 @@ class MicrosoftXDRPlatform(AbstractPlatform):
574
573
  url=api_url, payload=alert_rule, tenant_id=tenant_id
575
574
  )
576
575
 
577
- if status_code == 400:
578
- self.logger.error(
579
- f"Could not export the rule {rule_file} due to a bad request. {response['error']['message']}",
580
- extra={
581
- "rule_file": rule_file,
582
- "rule_converted": rule_converted,
583
- "rule_content": rule_content,
584
- "error": response,
585
- },
586
- )
587
- elif status_code == 403:
588
- self.logger.error(
589
- f"Could not export the rule {rule_file} due to insufficient permissions. {response}",
590
- extra={
591
- "rule_file": rule_file,
592
- "rule_converted": rule_converted,
593
- "rule_content": rule_content,
594
- "error": response,
595
- },
596
- )
597
- elif status_code == 201 or 200:
598
- if "error" in response:
576
+ if status_code in (200, 201, 204):
577
+ if isinstance(response, dict) and "error" in response:
599
578
  self.logger.error(
600
579
  f"Could not export the rule {rule_file}",
601
580
  extra={
@@ -606,19 +585,34 @@ class MicrosoftXDRPlatform(AbstractPlatform):
606
585
  },
607
586
  )
608
587
  raise Exception(response)
609
- else:
610
- self.logger.info(
611
- f"Successfully exported the rule {rule_file}",
612
- extra={
613
- "rule_file": rule_file,
614
- "rule_converted": rule_converted,
615
- "rule_content": rule_content,
616
- },
617
- )
618
- time.sleep(6)
588
+ self.logger.info(
589
+ f"Successfully exported the rule {rule_file}",
590
+ extra={
591
+ "rule_file": rule_file,
592
+ "rule_converted": rule_converted,
593
+ "rule_content": rule_content,
594
+ },
595
+ )
596
+ time.sleep(6)
597
+ return True
598
+
599
+ if status_code == 400:
600
+ reason = f"due to a bad request. {get_error_message(response)}"
601
+ elif status_code == 403:
602
+ reason = f"due to insufficient permissions. {get_error_message(response)}"
619
603
  else:
620
- print(status_code)
621
- pprint(response)
604
+ reason = f"due to an unexpected status code {status_code}. {get_error_message(response)}"
605
+
606
+ self.logger.error(
607
+ f"Could not export the rule {rule_file} {reason}",
608
+ extra={
609
+ "rule_file": rule_file,
610
+ "rule_converted": rule_converted,
611
+ "rule_content": rule_content,
612
+ "error": response,
613
+ },
614
+ )
615
+ raise Exception(f"Could not export the rule {rule_file} {reason}")
622
616
 
623
617
  def parse_actions(self, actions, rule_file=None):
624
618
  # TODO: It might be better to have a schema validation for the actions
@@ -409,9 +409,9 @@ class SentinelPlatform(AbstractPlatform):
409
409
  if results.status == LogsQueryStatus.SUCCESS:
410
410
  result = sum(len(table.rows) for table in results.tables)
411
411
  if result > 0:
412
- self.logger.warning(f"(Sentinel MSSP) Results for {customer_name or workspace_name}: {result}, {rule_file}")
412
+ self.logger.warning(f"(Sentinel MSSP) {result} hit(s) found for {rule_file} on customer '{customer_name or workspace_name}'")
413
413
  else:
414
- self.logger.info(f"(Sentinel MSSP) No results for {customer_name or workspace_name}, {rule_file}")
414
+ self.logger.info(f"(Sentinel MSSP) No hits for {rule_file} on customer '{customer_name or workspace_name}'")
415
415
  total_result += result
416
416
  elif results.status == LogsQueryStatus.PARTIAL:
417
417
  self.logger.error(f"Rule {rule_file} partial error for {customer_name or workspace_name}: {results.partial_error}")
@@ -456,9 +456,9 @@ class SentinelPlatform(AbstractPlatform):
456
456
 
457
457
  for customer, result in results.items():
458
458
  if result > 0:
459
- self.logger.warning(f"(Sentinel MSSP) Results for {customer}: {result}, {rule_file}")
459
+ self.logger.warning(f"(Sentinel MSSP) {result} hit(s) found for {rule_file} on customer '{customer}'")
460
460
  else:
461
- self.logger.info(f"(Sentinel MSSP) No results for {customer}, {rule_file}")
461
+ self.logger.info(f"(Sentinel MSSP) No hits for {rule_file} on customer '{customer}'")
462
462
  total_result += result
463
463
 
464
464
  return total_result
@@ -594,10 +594,10 @@ class SentinelPlatform(AbstractPlatform):
594
594
  self.logger.error(f"Failed to delete the rule {rule_file} from {workspace_name} - error: {e}")
595
595
  error = True
596
596
  if error:
597
- raise
597
+ raise Exception(f"Could not delete the rule {rule_file} from one or more workspaces")
598
598
  else:
599
599
  self.logger.error("Export list not found. Please provide the list of designated customers")
600
- raise
600
+ raise Exception("Export list not found. Please provide the list of designated customers")
601
601
  else:
602
602
  credential = self.get_credentials()
603
603
  client = SecurityInsights(credential, self._subscription_id)
@@ -798,10 +798,10 @@ class SentinelPlatform(AbstractPlatform):
798
798
  self.logger.error(f"Failed to export the rule {rule_file} to {workspace_name} - error: {e}")
799
799
  error = True
800
800
  if error:
801
- raise
801
+ raise Exception(f"Could not export the rule {rule_file} to one or more workspaces")
802
802
  else:
803
803
  self.logger.error("Export list not found. Please provide the list of designated customers")
804
- raise
804
+ raise Exception("Export list not found. Please provide the list of designated customers")
805
805
  else:
806
806
  credential = self.get_credentials()
807
807
  client = SecurityInsights(credential, self._subscription_id)
@@ -35,7 +35,7 @@ def search_rule_splunk(rule_converted, platform: SplunkPlatform, rule_file, para
35
35
 
36
36
  if result["resultCount"] > 0: # If the rule has match
37
37
  job_url = result["jobUrl"]
38
- logger.warning(f"(Splunk) Match found for {rule_file} - {job_url}")
38
+ logger.warning(f"(Splunk) {result['resultCount']} hit(s) found for {rule_file} - {job_url}")
39
39
  search_warning = True
40
40
  return error, search_warning
41
41
  else:
@@ -56,7 +56,7 @@ def search_rule_sentinel_mssp(rule_converted, rule_content, platform: SentinelPl
56
56
  logger.info(f"Successfully searched the rule {rule_file}")
57
57
 
58
58
  if result > 0: # If the rule has match
59
- logger.warning(f"(Sentinel MSSP) Match found for {rule_file} - {result} hit(s)")
59
+ logger.warning(f"(Sentinel MSSP) {result} hit(s) found in total for {rule_file}")
60
60
  search_warning = True
61
61
  return error, search_warning
62
62
  else:
@@ -76,7 +76,7 @@ def search_rule_sentinel(rule_converted, platform: SentinelPlatform, rule_file,
76
76
  logger.info(f"Successfully searched the rule {rule_file}")
77
77
 
78
78
  if result > 0: # If the rule has match
79
- logger.warning(f"(Sentinel) Match found for {rule_file} - {result} hit(s)")
79
+ logger.warning(f"(Sentinel) {result} hit(s) found for {rule_file}")
80
80
  search_warning = True
81
81
  return error, search_warning
82
82
  else:
@@ -98,11 +98,14 @@ def search_rule_ms_xdr_mssp(rule_converted, rule_content, platform: MicrosoftXDR
98
98
 
99
99
  logger.info("Searching for designated customers")
100
100
 
101
+ total_result = 0
102
+
101
103
  for group, info in export_list.items():
102
104
 
103
105
  tenant_id = info['tenant_id']
104
106
  customer_name = info.get('customer_name')
105
107
  customer_filter_dir = info.get('customer_filters_directory')
108
+ customer = customer_name or tenant_id
106
109
 
107
110
  logger.debug(f"Processing rule on {tenant_id} from group id {group}")
108
111
 
@@ -124,15 +127,20 @@ def search_rule_ms_xdr_mssp(rule_converted, rule_content, platform: MicrosoftXDR
124
127
  logger.info(f"Successfully searched the rule {rule_file}")
125
128
 
126
129
  if result > 0: # If the rule has a match
127
- logger.warning(f"{result} Matches found for {rule_file}")
130
+ logger.warning(f"(XDR MSSP) {result} hit(s) found for {rule_file} on customer '{customer}'")
128
131
  search_warning = True
129
132
  else:
130
- logger.info(f"No hits for {rule_file}")
133
+ logger.info(f"(XDR MSSP) No hits for {rule_file} on customer '{customer}'")
134
+
135
+ total_result += result
131
136
 
132
137
  except Exception as e:
133
138
  logger.error(f"Couldn't search for the rule {rule_file} for tenant {tenant_id} - error {e}")
134
139
  error = True
135
140
 
141
+ if total_result > 0:
142
+ logger.warning(f"(XDR MSSP) {total_result} hit(s) found in total for {rule_file}")
143
+
136
144
  return error, search_warning
137
145
 
138
146
  def search_rule_ms_xdr(rule_converted, platform: MicrosoftXDRPlatform, rule_file, parameters, logger, error, search_warning):
@@ -143,11 +151,11 @@ def search_rule_ms_xdr(rule_converted, platform: MicrosoftXDRPlatform, rule_file
143
151
  logger.info(f"Successfully searched the rule {rule_file}")
144
152
 
145
153
  if result > 0: # If the rule has match
146
- logger.warning(f"{result} Matches found for {rule_file}")
154
+ logger.warning(f"(XDR) {result} hit(s) found for {rule_file}")
147
155
  search_warning = True
148
156
  return error, search_warning
149
157
  else:
150
- logger.info(f"No hits for {rule_file}")
158
+ logger.info(f"(XDR) No hits for {rule_file}")
151
159
  return error, search_warning
152
160
 
153
161
  except Exception as e:
@@ -163,7 +171,7 @@ def search_rule_elastic(rule_converted, platform: ElasticPlatform, rule_file, pa
163
171
  logger.info(f"Successfully searched the rule {rule_file}")
164
172
 
165
173
  if result > 0: # If the rule has match
166
- logger.warning(f"(Elastic) {result} hits found for {rule_file}")
174
+ logger.warning(f"(Elastic) {result} hit(s) found for {rule_file}")
167
175
  search_warning = True
168
176
  return error, search_warning
169
177
  else:
@@ -0,0 +1,142 @@
1
+ """
2
+ Tests that export failures are propagated to the caller
3
+
4
+ A failure returned by the platform API (HTTP 400/403) must raise so that
5
+ export_rule() flags the error and droid exits with a non-zero code.
6
+ """
7
+
8
+ import time
9
+
10
+ import pytest
11
+
12
+ from droid.export import export_rule
13
+ from droid.platforms.ms_xdr import MicrosoftXDRPlatform
14
+
15
+ LOGGER_PARAM = {
16
+ "debug_mode": False,
17
+ "json_enabled": False,
18
+ "json_stdout": False,
19
+ "log_file": None,
20
+ }
21
+
22
+ RULE_FILE = "rules/sigma/delivery/anchovy_smuggling_on_neptune.yml"
23
+
24
+ RULE_CONTENT = {
25
+ "id": "0c1b1b1a-0000-0000-0000-0000deadbeef",
26
+ "title": "Anchovy smuggling",
27
+ "description": "Detects anchovies being delivered to Neptune",
28
+ "level": "medium",
29
+ "tags": ["attack.execution"],
30
+ }
31
+
32
+ RULE_CONVERTED = "DeviceNetworkEvents | where RemoteUrl has 'panucci'"
33
+
34
+ BAD_REQUEST = (
35
+ {"error": {"code": "BadRequest", "message": "Invalid query"}},
36
+ 400,
37
+ )
38
+
39
+
40
+ def build_platform(export_mssp: bool):
41
+ parameters = {
42
+ "query_period": "24H",
43
+ "search_auth": "app",
44
+ "export_auth": "app",
45
+ "tenant_id": "tenant-new-new-york",
46
+ "client_id": "bender-bending-rodriguez",
47
+ "client_secret": "shiny-metal-secret",
48
+ "export_list_mssp": {
49
+ "group1": {"tenant_id": "tenant-planet-express", "customer_name": "Planet Express"},
50
+ "group2": {"tenant_id": "tenant-panucci", "customer_name": "Panucci's Pizza"},
51
+ },
52
+ }
53
+ return MicrosoftXDRPlatform(parameters, LOGGER_PARAM, export_mssp=export_mssp)
54
+
55
+
56
+ @pytest.fixture
57
+ def xdr_bad_request(monkeypatch):
58
+ """Platform factory whose Graph calls always answer 400 Bad Request."""
59
+
60
+ def _factory(export_mssp):
61
+ platform = build_platform(export_mssp)
62
+ # No existing rule -> create path, which answers 400
63
+ monkeypatch.setattr(platform, "_get", lambda *a, **kw: ({"value": []}, 200))
64
+ monkeypatch.setattr(platform, "_post", lambda *a, **kw: BAD_REQUEST)
65
+ monkeypatch.setattr(platform, "_patch", lambda *a, **kw: BAD_REQUEST)
66
+ return platform
67
+
68
+ return _factory
69
+
70
+
71
+ @pytest.mark.parametrize("export_mssp", [False, True])
72
+ def test_xdr_create_rule_raises_on_bad_request(xdr_bad_request, export_mssp):
73
+ """A 400 from Graph must raise instead of silently returning"""
74
+ platform = xdr_bad_request(export_mssp)
75
+
76
+ with pytest.raises(Exception):
77
+ platform.create_rule(RULE_CONTENT, RULE_CONVERTED, RULE_FILE)
78
+
79
+
80
+ @pytest.mark.parametrize("export_mssp", [False, True])
81
+ def test_xdr_create_rule_succeeds_on_created(monkeypatch, export_mssp):
82
+ """A 201 from Graph must not raise"""
83
+ platform = build_platform(export_mssp)
84
+ monkeypatch.setattr(platform, "_get", lambda *a, **kw: ({"value": []}, 200))
85
+ monkeypatch.setattr(platform, "_post", lambda *a, **kw: ({"id": "rule-anchovy"}, 201))
86
+ monkeypatch.setattr(time, "sleep", lambda *a: None)
87
+
88
+ error = export_rule(
89
+ parameters={},
90
+ rule_content=RULE_CONTENT,
91
+ rule_converted=RULE_CONVERTED,
92
+ platform=platform,
93
+ rule_file=RULE_FILE,
94
+ error=False,
95
+ logger_param=LOGGER_PARAM,
96
+ )
97
+
98
+ assert error is False
99
+
100
+
101
+ def test_xdr_mssp_partial_failure_flags_error(monkeypatch):
102
+ """One failing tenant out of two must still flag the export as failed"""
103
+ platform = build_platform(export_mssp=True)
104
+ monkeypatch.setattr(platform, "_get", lambda *a, **kw: ({"value": []}, 200))
105
+ monkeypatch.setattr(time, "sleep", lambda *a: None)
106
+
107
+ def _post(*args, tenant_id=None, **kwargs):
108
+ if tenant_id == "tenant-panucci":
109
+ return BAD_REQUEST
110
+ return {"id": "rule-anchovy"}, 201
111
+
112
+ monkeypatch.setattr(platform, "_post", _post)
113
+
114
+ error = export_rule(
115
+ parameters={},
116
+ rule_content=RULE_CONTENT,
117
+ rule_converted=RULE_CONVERTED,
118
+ platform=platform,
119
+ rule_file=RULE_FILE,
120
+ error=False,
121
+ logger_param=LOGGER_PARAM,
122
+ )
123
+
124
+ assert error is True
125
+
126
+
127
+ @pytest.mark.parametrize("export_mssp", [False, True])
128
+ def test_export_rule_flags_error_on_bad_request(xdr_bad_request, export_mssp):
129
+ """export_rule() must report error=True so the CLI exits non-zero"""
130
+ platform = xdr_bad_request(export_mssp)
131
+
132
+ error = export_rule(
133
+ parameters={},
134
+ rule_content=RULE_CONTENT,
135
+ rule_converted=RULE_CONVERTED,
136
+ platform=platform,
137
+ rule_file=RULE_FILE,
138
+ error=False,
139
+ logger_param=LOGGER_PARAM,
140
+ )
141
+
142
+ assert error is True