contentctl 5.0.0a2__py3-none-any.whl → 5.0.1__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.
Files changed (114) hide show
  1. contentctl/__init__.py +1 -1
  2. contentctl/actions/build.py +88 -55
  3. contentctl/actions/deploy_acs.py +29 -24
  4. contentctl/actions/detection_testing/DetectionTestingManager.py +66 -41
  5. contentctl/actions/detection_testing/GitService.py +2 -4
  6. contentctl/actions/detection_testing/generate_detection_coverage_badge.py +48 -30
  7. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +163 -124
  8. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +45 -32
  9. contentctl/actions/detection_testing/progress_bar.py +3 -0
  10. contentctl/actions/detection_testing/views/DetectionTestingView.py +15 -18
  11. contentctl/actions/detection_testing/views/DetectionTestingViewCLI.py +1 -5
  12. contentctl/actions/detection_testing/views/DetectionTestingViewFile.py +2 -2
  13. contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py +1 -4
  14. contentctl/actions/doc_gen.py +9 -5
  15. contentctl/actions/initialize.py +45 -33
  16. contentctl/actions/inspect.py +118 -61
  17. contentctl/actions/new_content.py +83 -53
  18. contentctl/actions/release_notes.py +276 -146
  19. contentctl/actions/reporting.py +23 -19
  20. contentctl/actions/test.py +31 -25
  21. contentctl/actions/validate.py +54 -34
  22. contentctl/api.py +54 -45
  23. contentctl/contentctl.py +10 -10
  24. contentctl/enrichments/attack_enrichment.py +112 -72
  25. contentctl/enrichments/cve_enrichment.py +34 -28
  26. contentctl/enrichments/splunk_app_enrichment.py +38 -36
  27. contentctl/helper/link_validator.py +101 -78
  28. contentctl/helper/splunk_app.py +69 -41
  29. contentctl/helper/utils.py +58 -39
  30. contentctl/input/director.py +69 -37
  31. contentctl/input/new_content_questions.py +26 -34
  32. contentctl/input/yml_reader.py +22 -17
  33. contentctl/objects/abstract_security_content_objects/detection_abstract.py +255 -323
  34. contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +111 -46
  35. contentctl/objects/alert_action.py +8 -8
  36. contentctl/objects/annotated_types.py +1 -1
  37. contentctl/objects/atomic.py +64 -54
  38. contentctl/objects/base_test.py +2 -1
  39. contentctl/objects/base_test_result.py +16 -8
  40. contentctl/objects/baseline.py +47 -35
  41. contentctl/objects/baseline_tags.py +29 -22
  42. contentctl/objects/config.py +1 -1
  43. contentctl/objects/constants.py +32 -58
  44. contentctl/objects/correlation_search.py +75 -55
  45. contentctl/objects/dashboard.py +55 -41
  46. contentctl/objects/data_source.py +13 -13
  47. contentctl/objects/deployment.py +44 -37
  48. contentctl/objects/deployment_email.py +1 -1
  49. contentctl/objects/deployment_notable.py +2 -1
  50. contentctl/objects/deployment_phantom.py +5 -5
  51. contentctl/objects/deployment_rba.py +1 -1
  52. contentctl/objects/deployment_scheduling.py +1 -1
  53. contentctl/objects/deployment_slack.py +1 -1
  54. contentctl/objects/detection.py +5 -2
  55. contentctl/objects/detection_metadata.py +1 -0
  56. contentctl/objects/detection_stanza.py +7 -2
  57. contentctl/objects/detection_tags.py +54 -64
  58. contentctl/objects/drilldown.py +66 -35
  59. contentctl/objects/enums.py +61 -43
  60. contentctl/objects/errors.py +16 -24
  61. contentctl/objects/integration_test.py +3 -3
  62. contentctl/objects/integration_test_result.py +1 -0
  63. contentctl/objects/investigation.py +53 -31
  64. contentctl/objects/investigation_tags.py +29 -17
  65. contentctl/objects/lookup.py +234 -113
  66. contentctl/objects/macro.py +55 -38
  67. contentctl/objects/manual_test.py +3 -3
  68. contentctl/objects/manual_test_result.py +1 -0
  69. contentctl/objects/mitre_attack_enrichment.py +17 -16
  70. contentctl/objects/notable_action.py +2 -1
  71. contentctl/objects/notable_event.py +1 -3
  72. contentctl/objects/playbook.py +37 -35
  73. contentctl/objects/playbook_tags.py +22 -16
  74. contentctl/objects/rba.py +68 -11
  75. contentctl/objects/risk_analysis_action.py +15 -11
  76. contentctl/objects/risk_event.py +27 -20
  77. contentctl/objects/risk_object.py +1 -0
  78. contentctl/objects/savedsearches_conf.py +9 -7
  79. contentctl/objects/security_content_object.py +5 -2
  80. contentctl/objects/story.py +54 -49
  81. contentctl/objects/story_tags.py +56 -44
  82. contentctl/objects/test_group.py +5 -2
  83. contentctl/objects/threat_object.py +1 -0
  84. contentctl/objects/throttling.py +27 -18
  85. contentctl/objects/unit_test.py +3 -4
  86. contentctl/objects/unit_test_baseline.py +4 -5
  87. contentctl/objects/unit_test_result.py +6 -6
  88. contentctl/output/api_json_output.py +22 -22
  89. contentctl/output/attack_nav_output.py +21 -21
  90. contentctl/output/attack_nav_writer.py +29 -37
  91. contentctl/output/conf_output.py +230 -174
  92. contentctl/output/data_source_writer.py +38 -25
  93. contentctl/output/doc_md_output.py +53 -27
  94. contentctl/output/jinja_writer.py +19 -15
  95. contentctl/output/json_writer.py +20 -8
  96. contentctl/output/svg_output.py +56 -38
  97. contentctl/output/templates/analyticstories_detections.j2 +1 -1
  98. contentctl/output/templates/analyticstories_stories.j2 +1 -1
  99. contentctl/output/templates/es_investigations_investigations.j2 +1 -1
  100. contentctl/output/templates/es_investigations_stories.j2 +1 -1
  101. contentctl/output/templates/savedsearches_baselines.j2 +2 -2
  102. contentctl/output/templates/savedsearches_detections.j2 +2 -8
  103. contentctl/output/templates/savedsearches_investigations.j2 +2 -2
  104. contentctl/output/templates/transforms.j2 +2 -4
  105. contentctl/output/yml_writer.py +18 -24
  106. contentctl/templates/stories/cobalt_strike.yml +1 -0
  107. {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/METADATA +1 -1
  108. contentctl-5.0.1.dist-info/RECORD +168 -0
  109. contentctl/actions/initialize_old.py +0 -245
  110. contentctl/objects/observable.py +0 -39
  111. contentctl-5.0.0a2.dist-info/RECORD +0 -170
  112. {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/LICENSE.md +0 -0
  113. {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/WHEEL +0 -0
  114. {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/entry_points.txt +0 -0
@@ -1,66 +1,65 @@
1
1
  from __future__ import annotations
2
- from typing import TYPE_CHECKING, Union, Optional, List, Any, Annotated
3
- import re
2
+
4
3
  import pathlib
4
+ import re
5
5
  from enum import StrEnum
6
+ from typing import TYPE_CHECKING, Annotated, Any, List, Optional, Union
6
7
 
7
8
  from pydantic import (
8
- field_validator,
9
- model_validator,
10
- ValidationInfo,
11
9
  Field,
10
+ FilePath,
11
+ ValidationInfo,
12
12
  computed_field,
13
+ field_validator,
13
14
  model_serializer,
14
- ConfigDict,
15
- FilePath
15
+ model_validator,
16
16
  )
17
17
 
18
+ from contentctl.objects.lookup import FileBackedLookup, KVStoreLookup, Lookup
18
19
  from contentctl.objects.macro import Macro
19
- from contentctl.objects.lookup import Lookup, FileBackedLookup, KVStoreLookup
20
+
20
21
  if TYPE_CHECKING:
21
22
  from contentctl.input.director import DirectorOutputDto
22
23
  from contentctl.objects.baseline import Baseline
23
24
  from contentctl.objects.config import CustomApp
24
-
25
- from contentctl.objects.security_content_object import SecurityContentObject
26
- from contentctl.objects.enums import AnalyticsType
27
- from contentctl.objects.enums import DataModel
28
- from contentctl.objects.enums import DetectionStatus
29
- from contentctl.objects.enums import NistCategory
30
-
31
- from contentctl.objects.detection_tags import DetectionTags
32
- from contentctl.objects.deployment import Deployment
33
- from contentctl.objects.unit_test import UnitTest
34
- from contentctl.objects.manual_test import ManualTest
35
- from contentctl.objects.test_group import TestGroup
36
- from contentctl.objects.integration_test import IntegrationTest
37
- from contentctl.objects.data_source import DataSource
38
25
 
39
- from contentctl.objects.rba import RBAObject
26
+ import datetime
40
27
 
41
- from contentctl.objects.base_test_result import TestResultStatus
42
- from contentctl.objects.drilldown import Drilldown, DRILLDOWN_SEARCH_PLACEHOLDER
43
- from contentctl.objects.enums import ProvidingTechnology
44
28
  from contentctl.enrichments.cve_enrichment import CveEnrichmentObj
45
- import datetime
29
+ from contentctl.objects.base_test_result import TestResultStatus
46
30
  from contentctl.objects.constants import (
31
+ CONTENTCTL_DETECTION_STANZA_NAME_FORMAT_TEMPLATE,
32
+ CONTENTCTL_MAX_SEARCH_NAME_LENGTH,
47
33
  ES_MAX_STANZA_LENGTH,
48
34
  ES_SEARCH_STANZA_NAME_FORMAT_AFTER_CLONING_IN_PRODUCT_TEMPLATE,
49
- CONTENTCTL_MAX_SEARCH_NAME_LENGTH,
50
- CONTENTCTL_DETECTION_STANZA_NAME_FORMAT_TEMPLATE
51
35
  )
36
+ from contentctl.objects.data_source import DataSource
37
+ from contentctl.objects.deployment import Deployment
38
+ from contentctl.objects.detection_tags import DetectionTags
39
+ from contentctl.objects.drilldown import DRILLDOWN_SEARCH_PLACEHOLDER, Drilldown
40
+ from contentctl.objects.enums import (
41
+ AnalyticsType,
42
+ DataModel,
43
+ DetectionStatus,
44
+ NistCategory,
45
+ ProvidingTechnology,
46
+ )
47
+ from contentctl.objects.integration_test import IntegrationTest
48
+ from contentctl.objects.manual_test import ManualTest
49
+ from contentctl.objects.rba import RBAObject
50
+ from contentctl.objects.security_content_object import SecurityContentObject
51
+ from contentctl.objects.test_group import TestGroup
52
+ from contentctl.objects.unit_test import UnitTest
52
53
 
53
54
  MISSING_SOURCES: set[str] = set()
54
55
 
55
56
  # Those AnalyticsTypes that we do not test via contentctl
56
- SKIPPED_ANALYTICS_TYPES: set[str] = {
57
- AnalyticsType.Correlation
58
- }
57
+ SKIPPED_ANALYTICS_TYPES: set[str] = {AnalyticsType.Correlation}
59
58
 
60
59
 
61
60
  class Detection_Abstract(SecurityContentObject):
62
- name:str = Field(...,max_length=CONTENTCTL_MAX_SEARCH_NAME_LENGTH)
63
- #contentType: SecurityContentType = SecurityContentType.detections
61
+ name: str = Field(..., max_length=CONTENTCTL_MAX_SEARCH_NAME_LENGTH)
62
+ # contentType: SecurityContentType = SecurityContentType.detections
64
63
  type: AnalyticsType = Field(...)
65
64
  status: DetectionStatus = Field(...)
66
65
  data_source: list[str] = []
@@ -71,16 +70,15 @@ class Detection_Abstract(SecurityContentObject):
71
70
  rba: Optional[RBAObject] = Field(default=None)
72
71
  explanation: None | str = Field(
73
72
  default=None,
74
- exclude=True, #Don't serialize this value when dumping the object
73
+ exclude=True, # Don't serialize this value when dumping the object
75
74
  description="Provide an explanation to be included "
76
75
  "in the 'Explanation' field of the Detection in "
77
76
  "the Use Case Library. If this field is not "
78
77
  "defined in the YML, it will default to the "
79
- "value of the 'description' field when "
78
+ "value of the 'description' field when "
80
79
  "serialized in analyticstories_detections.j2",
81
80
  )
82
81
 
83
-
84
82
  enabled_by_default: bool = False
85
83
  file_path: FilePath = Field(...)
86
84
  # For model construction to first attempt construction of the leftmost object.
@@ -88,36 +86,49 @@ class Detection_Abstract(SecurityContentObject):
88
86
  # default mode, 'smart'
89
87
  # https://docs.pydantic.dev/latest/concepts/unions/#left-to-right-mode
90
88
  # https://github.com/pydantic/pydantic/issues/9101#issuecomment-2019032541
91
- tests: List[Annotated[Union[UnitTest, IntegrationTest, ManualTest], Field(union_mode='left_to_right')]] = []
89
+ tests: List[
90
+ Annotated[
91
+ Union[UnitTest, IntegrationTest, ManualTest],
92
+ Field(union_mode="left_to_right"),
93
+ ]
94
+ ] = []
92
95
  # A list of groups of tests, relying on the same data
93
96
  test_groups: list[TestGroup] = []
94
97
 
95
98
  data_source_objects: list[DataSource] = []
96
- drilldown_searches: list[Drilldown] = Field(default=[], description="A list of Drilldowns that should be included with this search")
99
+ drilldown_searches: list[Drilldown] = Field(
100
+ default=[],
101
+ description="A list of Drilldowns that should be included with this search",
102
+ )
97
103
 
98
- def get_conf_stanza_name(self, app:CustomApp)->str:
99
- stanza_name = CONTENTCTL_DETECTION_STANZA_NAME_FORMAT_TEMPLATE.format(app_label=app.label, detection_name=self.name)
104
+ def get_conf_stanza_name(self, app: CustomApp) -> str:
105
+ stanza_name = CONTENTCTL_DETECTION_STANZA_NAME_FORMAT_TEMPLATE.format(
106
+ app_label=app.label, detection_name=self.name
107
+ )
100
108
  self.check_conf_stanza_max_length(stanza_name)
101
109
  return stanza_name
102
-
103
110
 
104
- def get_action_dot_correlationsearch_dot_label(self, app:CustomApp, max_stanza_length:int=ES_MAX_STANZA_LENGTH)->str:
111
+ def get_action_dot_correlationsearch_dot_label(
112
+ self, app: CustomApp, max_stanza_length: int = ES_MAX_STANZA_LENGTH
113
+ ) -> str:
105
114
  stanza_name = self.get_conf_stanza_name(app)
106
- stanza_name_after_saving_in_es = ES_SEARCH_STANZA_NAME_FORMAT_AFTER_CLONING_IN_PRODUCT_TEMPLATE.format(
107
- security_domain_value = self.tags.security_domain,
108
- search_name = stanza_name
115
+ stanza_name_after_saving_in_es = (
116
+ ES_SEARCH_STANZA_NAME_FORMAT_AFTER_CLONING_IN_PRODUCT_TEMPLATE.format(
117
+ security_domain_value=self.tags.security_domain, search_name=stanza_name
109
118
  )
110
-
111
-
119
+ )
120
+
112
121
  if len(stanza_name_after_saving_in_es) > max_stanza_length:
113
- raise ValueError(f"label may only be {max_stanza_length} characters to allow updating in-product, "
114
- f"but stanza was actually {len(stanza_name_after_saving_in_es)} characters: '{stanza_name_after_saving_in_es}' ")
115
-
116
- return stanza_name
122
+ raise ValueError(
123
+ f"label may only be {max_stanza_length} characters to allow updating in-product, "
124
+ f"but stanza was actually {len(stanza_name_after_saving_in_es)} characters: '{stanza_name_after_saving_in_es}' "
125
+ )
126
+
127
+ return stanza_name
117
128
 
118
129
  @field_validator("search", mode="before")
119
130
  @classmethod
120
- def validate_presence_of_filter_macro(cls, value:str, info:ValidationInfo)->str:
131
+ def validate_presence_of_filter_macro(cls, value: str, info: ValidationInfo) -> str:
121
132
  """
122
133
  Validates that, if required to be present, the filter macro is present with the proper name.
123
134
  The filter macro MUST be derived from the name of the detection
@@ -131,7 +142,7 @@ class Detection_Abstract(SecurityContentObject):
131
142
  Returns:
132
143
  str: The search, as an SPL formatted string.
133
144
  """
134
-
145
+
135
146
  # Otherwise, the search is SPL.
136
147
 
137
148
  # In the future, we will may add support that makes the inclusion of the
@@ -171,7 +182,7 @@ class Detection_Abstract(SecurityContentObject):
171
182
  the model from the list of unit tests. Also, preemptively skips all manual tests, as well as
172
183
  tests for experimental/deprecated detections and Correlation type detections.
173
184
  """
174
-
185
+
175
186
  # Since ManualTest and UnitTest are not differentiable without looking at the manual_test
176
187
  # tag, Pydantic builds all tests as UnitTest objects. If we see the manual_test flag, we
177
188
  # convert these to ManualTest
@@ -184,10 +195,7 @@ class Detection_Abstract(SecurityContentObject):
184
195
  f"but encountered a {type(test)}."
185
196
  )
186
197
  # Create the manual test and skip it upon creation (cannot test via contentctl)
187
- manual_test = ManualTest(
188
- name=test.name,
189
- attack_data=test.attack_data
190
- )
198
+ manual_test = ManualTest(name=test.name, attack_data=test.attack_data)
191
199
  tmp.append(manual_test)
192
200
  self.tests = tmp
193
201
 
@@ -213,8 +221,10 @@ class Detection_Abstract(SecurityContentObject):
213
221
  # https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
214
222
 
215
223
  # Skip tests for non-production detections
216
- if self.status != DetectionStatus.production:
217
- self.skip_all_tests(f"TEST SKIPPED: Detection is non-production ({self.status})")
224
+ if self.status != DetectionStatus.production:
225
+ self.skip_all_tests(
226
+ f"TEST SKIPPED: Detection is non-production ({self.status})"
227
+ )
218
228
 
219
229
  # Skip tests for detecton types like Correlation which are not supported via contentctl
220
230
  if self.type in SKIPPED_ANALYTICS_TYPES:
@@ -241,7 +251,10 @@ class Detection_Abstract(SecurityContentObject):
241
251
  # If the result/status of any test has not yet been set, return None
242
252
  if test.result is None or test.result.status is None:
243
253
  return None
244
- elif test.result.status == TestResultStatus.ERROR or test.result.status == TestResultStatus.FAIL:
254
+ elif (
255
+ test.result.status == TestResultStatus.ERROR
256
+ or test.result.status == TestResultStatus.FAIL
257
+ ):
245
258
  # If any test failed or errored, return fail (we don't return the error state at
246
259
  # the aggregate detection level)
247
260
  return TestResultStatus.FAIL
@@ -267,24 +280,21 @@ class Detection_Abstract(SecurityContentObject):
267
280
  @property
268
281
  def datamodel(self) -> List[DataModel]:
269
282
  return [dm for dm in DataModel if dm in self.search]
270
-
271
-
272
-
273
283
 
274
284
  @computed_field
275
285
  @property
276
286
  def source(self) -> str:
277
287
  return self.file_path.absolute().parent.name
278
-
279
288
 
280
289
  deployment: Deployment = Field({})
281
290
 
282
291
  @computed_field
283
292
  @property
284
293
  def annotations(self) -> dict[str, Union[List[str], int, str]]:
285
-
286
294
  annotations_dict: dict[str, str | list[str] | int] = {}
287
- annotations_dict["analytic_story"] = [story.name for story in self.tags.analytic_story]
295
+ annotations_dict["analytic_story"] = [
296
+ story.name for story in self.tags.analytic_story
297
+ ]
288
298
  if len(self.tags.cve or []) > 0:
289
299
  annotations_dict["cve"] = self.tags.cve
290
300
  annotations_dict["type"] = self.type
@@ -311,11 +321,13 @@ class Detection_Abstract(SecurityContentObject):
311
321
  if len(self.tags.cis20) > 0:
312
322
  mappings["cis20"] = [tag for tag in self.tags.cis20]
313
323
  if len(self.tags.kill_chain_phases) > 0:
314
- mappings['kill_chain_phases'] = [phase for phase in self.tags.kill_chain_phases]
324
+ mappings["kill_chain_phases"] = [
325
+ phase for phase in self.tags.kill_chain_phases
326
+ ]
315
327
  if len(self.tags.mitre_attack_id) > 0:
316
- mappings['mitre_attack'] = self.tags.mitre_attack_id
328
+ mappings["mitre_attack"] = self.tags.mitre_attack_id
317
329
  if len(self.tags.nist) > 0:
318
- mappings['nist'] = [category for category in self.tags.nist]
330
+ mappings["nist"] = [category for category in self.tags.nist]
319
331
 
320
332
  # No need to sort the dict! It has been constructed in-order.
321
333
  # However, if this logic is changed, then consider reordering or
@@ -330,8 +342,10 @@ class Detection_Abstract(SecurityContentObject):
330
342
 
331
343
  def cve_enrichment_func(self, __context: Any):
332
344
  if len(self.cve_enrichment) > 0:
333
- raise ValueError(f"Error, field 'cve_enrichment' should be empty and "
334
- f"dynamically populated at runtime. Instead, this field contained: {self.cve_enrichment}")
345
+ raise ValueError(
346
+ f"Error, field 'cve_enrichment' should be empty and "
347
+ f"dynamically populated at runtime. Instead, this field contained: {self.cve_enrichment}"
348
+ )
335
349
 
336
350
  output_dto: Union[DirectorOutputDto, None] = __context.get("output_dto", None)
337
351
  if output_dto is None:
@@ -341,7 +355,11 @@ class Detection_Abstract(SecurityContentObject):
341
355
 
342
356
  for cve_id in self.tags.cve:
343
357
  try:
344
- enriched_cves.append(output_dto.cve_enrichment.enrich_cve(cve_id, raise_exception_on_failure=False))
358
+ enriched_cves.append(
359
+ output_dto.cve_enrichment.enrich_cve(
360
+ cve_id, raise_exception_on_failure=False
361
+ )
362
+ )
345
363
  except Exception as e:
346
364
  raise ValueError(f"{e}")
347
365
  self.cve_enrichment = enriched_cves
@@ -353,7 +371,7 @@ class Detection_Abstract(SecurityContentObject):
353
371
  @property
354
372
  def nes_fields(self) -> Optional[str]:
355
373
  if self.deployment.alert_action.notable is not None:
356
- return ','.join(self.deployment.alert_action.notable.nes_fields)
374
+ return ",".join(self.deployment.alert_action.notable.nes_fields)
357
375
  else:
358
376
  return None
359
377
 
@@ -362,91 +380,24 @@ class Detection_Abstract(SecurityContentObject):
362
380
  def providing_technologies(self) -> List[ProvidingTechnology]:
363
381
  return ProvidingTechnology.getProvidingTechFromSearch(self.search)
364
382
 
365
-
366
383
  @computed_field
367
384
  @property
368
385
  def risk(self) -> list[dict[str, Any]]:
369
- risk_objects: list[dict[str, str | int]] = []
370
-
371
- for entity in self.rba.risk_objects:
372
- risk_object: dict[str, str | int] = dict()
373
- risk_object['risk_object_type'] = entity.type
374
- risk_object['risk_object_field'] = entity.field
375
- risk_object['risk_score'] = entity.score
376
- risk_objects.append(risk_object)
377
-
378
- for entity in self.rba.threat_objects:
379
- threat_object: dict[str, str] = dict()
380
- threat_object['threat_object_field'] = entity.field
381
- threat_object['threat_object_type'] = entity.type
382
- risk_objects.append(threat_object)
383
- return risk_objects
384
-
385
-
386
- # TODO Remove observable code
387
- # @computed_field
388
- # @property
389
- # def risk(self) -> list[dict[str, Any]]:
390
- # risk_objects: list[dict[str, str | int]] = []
391
- # # TODO (#246): "User Name" type should map to a "user" risk object and not "other"
392
- # risk_object_user_types = {'user', 'username', 'email address'}
393
- # risk_object_system_types = {'device', 'endpoint', 'hostname', 'ip address'}
394
- # process_threat_object_types = {'process name', 'process'}
395
- # file_threat_object_types = {'file name', 'file', 'file hash'}
396
- # url_threat_object_types = {'url string', 'url'}
397
- # ip_threat_object_types = {'ip address'}
398
-
399
- # for entity in self.tags.observable:
400
- # risk_object: dict[str, str | int] = dict()
401
- # if 'Victim' in entity.role and entity.type.lower() in risk_object_user_types:
402
- # risk_object['risk_object_type'] = 'user'
403
- # risk_object['risk_object_field'] = entity.name
404
- # risk_object['risk_score'] = self.tags.risk_score
405
- # risk_objects.append(risk_object)
406
-
407
- # elif 'Victim' in entity.role and entity.type.lower() in risk_object_system_types:
408
- # risk_object['risk_object_type'] = 'system'
409
- # risk_object['risk_object_field'] = entity.name
410
- # risk_object['risk_score'] = self.tags.risk_score
411
- # risk_objects.append(risk_object)
412
-
413
- # elif 'Attacker' in entity.role and entity.type.lower() in process_threat_object_types:
414
- # risk_object['threat_object_field'] = entity.name
415
- # risk_object['threat_object_type'] = "process"
416
- # risk_objects.append(risk_object)
417
-
418
- # elif 'Attacker' in entity.role and entity.type.lower() in file_threat_object_types:
419
- # risk_object['threat_object_field'] = entity.name
420
- # risk_object['threat_object_type'] = "file_name"
421
- # risk_objects.append(risk_object)
422
-
423
- # elif 'Attacker' in entity.role and entity.type.lower() in ip_threat_object_types:
424
- # risk_object['threat_object_field'] = entity.name
425
- # risk_object['threat_object_type'] = "ip_address"
426
- # risk_objects.append(risk_object)
427
-
428
- # elif 'Attacker' in entity.role and entity.type.lower() in url_threat_object_types:
429
- # risk_object['threat_object_field'] = entity.name
430
- # risk_object['threat_object_type'] = "url"
431
- # risk_objects.append(risk_object)
432
-
433
- # elif 'Attacker' in entity.role:
434
- # risk_object['threat_object_field'] = entity.name
435
- # risk_object['threat_object_type'] = entity.type.lower()
436
- # risk_objects.append(risk_object)
437
-
438
- # else:
439
- # risk_object['risk_object_type'] = 'other'
440
- # risk_object['risk_object_field'] = entity.name
441
- # risk_object['risk_score'] = self.tags.risk_score
442
- # risk_objects.append(risk_object)
443
- # continue
444
-
445
- # return risk_objects
386
+ if self.rba is None:
387
+ raise Exception(
388
+ f"Attempting to serialize rba section of [{self.name}], however RBA section is None"
389
+ )
390
+ """
391
+ action.risk.param._risk
392
+ of the conf file only contains a list of dicts. We do not eant to
393
+ include the message here, so we do not return it.
394
+ """
395
+ rba_dict = self.rba.model_dump()
396
+ return rba_dict["risk_objects"] + rba_dict["threat_objects"]
446
397
 
447
398
  @computed_field
448
399
  @property
449
- def metadata(self) -> dict[str, str|float]:
400
+ def metadata(self) -> dict[str, str | float]:
450
401
  # NOTE: we ignore the type error around self.status because we are using Pydantic's
451
402
  # use_enum_values configuration
452
403
  # https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
@@ -456,10 +407,19 @@ class Detection_Abstract(SecurityContentObject):
456
407
  # dict below) should not have any impact, but renaming or removing any of these fields will
457
408
  # break the `inspect` action.
458
409
  return {
459
- 'detection_id': str(self.id),
460
- 'deprecated': '1' if self.status == DetectionStatus.deprecated else '0', # type: ignore
461
- 'detection_version': str(self.version),
462
- 'publish_time': datetime.datetime(self.date.year,self.date.month,self.date.day,0,0,0,0,tzinfo=datetime.timezone.utc).timestamp()
410
+ "detection_id": str(self.id),
411
+ "deprecated": "1" if self.status == DetectionStatus.deprecated else "0", # type: ignore
412
+ "detection_version": str(self.version),
413
+ "publish_time": datetime.datetime(
414
+ self.date.year,
415
+ self.date.month,
416
+ self.date.day,
417
+ 0,
418
+ 0,
419
+ 0,
420
+ 0,
421
+ tzinfo=datetime.timezone.utc,
422
+ ).timestamp(),
463
423
  }
464
424
 
465
425
  @model_serializer
@@ -480,9 +440,9 @@ class Detection_Abstract(SecurityContentObject):
480
440
  }
481
441
  if self.rba is not None:
482
442
  model["risk_severity"] = self.rba.severity
483
- model['tags']['risk_score'] = self.rba.risk_score
443
+ model["tags"]["risk_score"] = self.rba.risk_score
484
444
  else:
485
- model['tags']['risk_score'] = 0
445
+ model["tags"]["risk_score"] = 0
486
446
 
487
447
  # Only a subset of macro fields are required:
488
448
  all_macros: list[dict[str, str | list[str]]] = []
@@ -490,13 +450,13 @@ class Detection_Abstract(SecurityContentObject):
490
450
  macro_dump: dict[str, str | list[str]] = {
491
451
  "name": macro.name,
492
452
  "definition": macro.definition,
493
- "description": macro.description
453
+ "description": macro.description,
494
454
  }
495
455
  if len(macro.arguments) > 0:
496
- macro_dump['arguments'] = macro.arguments
456
+ macro_dump["arguments"] = macro.arguments
497
457
 
498
458
  all_macros.append(macro_dump)
499
- model['macros'] = all_macros # type: ignore
459
+ model["macros"] = all_macros # type: ignore
500
460
 
501
461
  all_lookups: list[dict[str, str | int | None]] = []
502
462
  for lookup in self.lookups:
@@ -507,7 +467,7 @@ class Detection_Abstract(SecurityContentObject):
507
467
  "description": lookup.description,
508
468
  "collection": lookup.collection,
509
469
  "case_sensitive_match": None,
510
- "fields_list": lookup.fields_to_fields_list_conf_format
470
+ "fields_list": lookup.fields_to_fields_list_conf_format,
511
471
  }
512
472
  )
513
473
  elif isinstance(lookup, FileBackedLookup):
@@ -517,15 +477,17 @@ class Detection_Abstract(SecurityContentObject):
517
477
  "description": lookup.description,
518
478
  "filename": lookup.filename.name,
519
479
  "default_match": "true" if lookup.default_match else "false",
520
- "case_sensitive_match": "true" if lookup.case_sensitive_match else "false",
480
+ "case_sensitive_match": "true"
481
+ if lookup.case_sensitive_match
482
+ else "false",
521
483
  "match_type": lookup.match_type_to_conf_format,
522
- "min_matches": lookup.min_matches
484
+ "min_matches": lookup.min_matches,
523
485
  }
524
486
  )
525
- model['lookups'] = all_lookups # type: ignore
487
+ model["lookups"] = all_lookups # type: ignore
526
488
 
527
489
  # Combine fields from this model with fields from parent
528
- super_fields.update(model) # type: ignore
490
+ super_fields.update(model) # type: ignore
529
491
 
530
492
  # return the model
531
493
  return super_fields
@@ -558,7 +520,7 @@ class Detection_Abstract(SecurityContentObject):
558
520
  updated_data_source_names: set[str] = set()
559
521
 
560
522
  for ds in self.data_source:
561
- split_data_sources = {d.strip() for d in ds.split('AND')}
523
+ split_data_sources = {d.strip() for d in ds.split("AND")}
562
524
  updated_data_source_names.update(split_data_sources)
563
525
 
564
526
  sources = sorted(list(updated_data_source_names))
@@ -567,7 +529,9 @@ class Detection_Abstract(SecurityContentObject):
567
529
  missing_sources: list[str] = []
568
530
  for source in sources:
569
531
  try:
570
- matched_data_sources += DataSource.mapNamesToSecurityContentObjects([source], director)
532
+ matched_data_sources += DataSource.mapNamesToSecurityContentObjects(
533
+ [source], director
534
+ )
571
535
  except Exception:
572
536
  # We gobble this up and add it to a global set so that we
573
537
  # can print it ONCE at the end of the build of datasources.
@@ -584,7 +548,7 @@ class Detection_Abstract(SecurityContentObject):
584
548
  self.data_source_objects = matched_data_sources
585
549
 
586
550
  for story in self.tags.analytic_story:
587
- story.detections.append(self)
551
+ story.detections.append(self)
588
552
 
589
553
  self.cve_enrichment_func(__context)
590
554
 
@@ -595,32 +559,39 @@ class Detection_Abstract(SecurityContentObject):
595
559
  # 1 of the drilldowns contains the string Drilldown.SEARCH_PLACEHOLDER.
596
560
  # This is presently a requirement when 1 or more drilldowns are added to a detection.
597
561
  # Note that this is only required for production searches that are not hunting
598
-
599
- if self.type == AnalyticsType.Hunting or self.status != DetectionStatus.production:
600
- #No additional check need to happen on the potential drilldowns.
562
+
563
+ if (
564
+ self.type == AnalyticsType.Hunting
565
+ or self.status != DetectionStatus.production
566
+ ):
567
+ # No additional check need to happen on the potential drilldowns.
601
568
  pass
602
569
  else:
603
570
  found_placeholder = False
604
571
  if len(self.drilldown_searches) < 2:
605
- raise ValueError(f"This detection is required to have 2 drilldown_searches, but only has [{len(self.drilldown_searches)}]")
572
+ raise ValueError(
573
+ f"This detection is required to have 2 drilldown_searches, but only has [{len(self.drilldown_searches)}]"
574
+ )
606
575
  for drilldown in self.drilldown_searches:
607
576
  if DRILLDOWN_SEARCH_PLACEHOLDER in drilldown.search:
608
577
  found_placeholder = True
609
578
  if not found_placeholder:
610
- raise ValueError("Detection has one or more drilldown_searches, but none of them "
611
- f"contained '{DRILLDOWN_SEARCH_PLACEHOLDER}. This is a requirement "
612
- "if drilldown_searches are defined.'")
613
-
579
+ raise ValueError(
580
+ "Detection has one or more drilldown_searches, but none of them "
581
+ f"contained '{DRILLDOWN_SEARCH_PLACEHOLDER}. This is a requirement "
582
+ "if drilldown_searches are defined.'"
583
+ )
584
+
614
585
  # Update the search fields with the original search, if required
615
586
  for drilldown in self.drilldown_searches:
616
587
  drilldown.perform_search_substitutions(self)
617
588
 
618
- #For experimental purposes, add the default drilldowns
619
- #self.drilldown_searches.extend(Drilldown.constructDrilldownsFromDetection(self))
589
+ # For experimental purposes, add the default drilldowns
590
+ # self.drilldown_searches.extend(Drilldown.constructDrilldownsFromDetection(self))
620
591
 
621
592
  @property
622
- def drilldowns_in_JSON(self) -> list[dict[str,str]]:
623
- """This function is required for proper JSON
593
+ def drilldowns_in_JSON(self) -> list[dict[str, str]]:
594
+ """This function is required for proper JSON
624
595
  serializiation of drilldowns to occur in savedsearches.conf.
625
596
  It returns the list[Drilldown] as a list[dict].
626
597
  Without this function, the jinja template is unable
@@ -628,24 +599,26 @@ class Detection_Abstract(SecurityContentObject):
628
599
 
629
600
  Returns:
630
601
  list[dict[str,str]]: List of Drilldowns dumped to dict format
631
- """
602
+ """
632
603
  return [drilldown.model_dump() for drilldown in self.drilldown_searches]
633
604
 
634
- @field_validator('lookups', mode="before")
605
+ @field_validator("lookups", mode="before")
635
606
  @classmethod
636
- def getDetectionLookups(cls, v:list[str], info:ValidationInfo) -> list[Lookup]:
637
- director:DirectorOutputDto = info.context.get("output_dto",None)
638
-
639
- search:Union[str,None] = info.data.get("search",None)
607
+ def getDetectionLookups(cls, v: list[str], info: ValidationInfo) -> list[Lookup]:
608
+ director: DirectorOutputDto = info.context.get("output_dto", None)
609
+
610
+ search: Union[str, None] = info.data.get("search", None)
640
611
  if search is None:
641
612
  raise ValueError("Search was None - is this file missing the search field?")
642
-
613
+
643
614
  lookups = Lookup.get_lookups(search, director)
644
615
  return lookups
645
616
 
646
- @field_validator('baselines', mode="before")
617
+ @field_validator("baselines", mode="before")
647
618
  @classmethod
648
- def mapDetectionNamesToBaselineObjects(cls, v: list[str], info: ValidationInfo) -> List[Baseline]:
619
+ def mapDetectionNamesToBaselineObjects(
620
+ cls, v: list[str], info: ValidationInfo
621
+ ) -> List[Baseline]:
649
622
  if len(v) > 0:
650
623
  raise ValueError(
651
624
  "Error, baselines are constructed automatically at runtime. Please do not include this field."
@@ -653,7 +626,9 @@ class Detection_Abstract(SecurityContentObject):
653
626
 
654
627
  name: Union[str, None] = info.data.get("name", None)
655
628
  if name is None:
656
- raise ValueError("Error, cannot get Baselines because the Detection does not have a 'name' defined.")
629
+ raise ValueError(
630
+ "Error, cannot get Baselines because the Detection does not have a 'name' defined."
631
+ )
657
632
 
658
633
  if info.context is None:
659
634
  raise ValueError("ValidationInfo.context unexpectedly null")
@@ -664,14 +639,16 @@ class Detection_Abstract(SecurityContentObject):
664
639
  # This matching is a bit strange, because baseline.tags.detections starts as a list of strings, but
665
640
  # is eventually updated to a list of Detections as we construct all of the detection objects.
666
641
  detection_names = [
667
- detection_name for detection_name in baseline.tags.detections if isinstance(detection_name, str)
642
+ detection_name
643
+ for detection_name in baseline.tags.detections
644
+ if isinstance(detection_name, str)
668
645
  ]
669
646
  if name in detection_names:
670
647
  baselines.append(baseline)
671
648
 
672
649
  return baselines
673
650
 
674
- @field_validator('macros', mode="before")
651
+ @field_validator("macros", mode="before")
675
652
  @classmethod
676
653
  def getDetectionMacros(cls, v: list[str], info: ValidationInfo) -> list[Macro]:
677
654
  if info.context is None:
@@ -687,21 +664,25 @@ class Detection_Abstract(SecurityContentObject):
687
664
  message = f"Expected 'search_name' to be a string, instead it was [{type(search_name)}]"
688
665
  assert isinstance(search_name, str), message
689
666
 
690
- filter_macro_name = search_name.replace(' ', '_')\
691
- .replace('-', '_')\
692
- .replace('.', '_')\
693
- .replace('/', '_')\
694
- .lower()\
695
- + '_filter'
667
+ filter_macro_name = (
668
+ search_name.replace(" ", "_")
669
+ .replace("-", "_")
670
+ .replace(".", "_")
671
+ .replace("/", "_")
672
+ .lower()
673
+ + "_filter"
674
+ )
696
675
  try:
697
- filter_macro = Macro.mapNamesToSecurityContentObjects([filter_macro_name], director)[0]
676
+ filter_macro = Macro.mapNamesToSecurityContentObjects(
677
+ [filter_macro_name], director
678
+ )[0]
698
679
  except Exception:
699
680
  # Filter macro did not exist, so create one at runtime
700
681
  filter_macro = Macro.model_validate(
701
682
  {
702
683
  "name": filter_macro_name,
703
- "definition": 'search *',
704
- "description": 'Update this macro to limit the output results to filter out false positives.'
684
+ "definition": "search *",
685
+ "description": "Update this macro to limit the output results to filter out false positives.",
705
686
  }
706
687
  )
707
688
  director.addContentToDictMappings(filter_macro)
@@ -724,12 +705,12 @@ class Detection_Abstract(SecurityContentObject):
724
705
 
725
706
  @field_validator("enabled_by_default", mode="before")
726
707
  def only_enabled_if_production_status(cls, v: Any, info: ValidationInfo) -> bool:
727
- '''
708
+ """
728
709
  A detection can ONLY be enabled by default if it is a PRODUCTION detection.
729
710
  If not (for example, it is EXPERIMENTAL or DEPRECATED) then we will throw an exception.
730
711
  Similarly, a detection MUST be schedulable, meaning that it must be Anomaly, Correleation, or TTP.
731
712
  We will not allow Hunting searches to be enabled by default.
732
- '''
713
+ """
733
714
  if v is False:
734
715
  return v
735
716
 
@@ -740,16 +721,23 @@ class Detection_Abstract(SecurityContentObject):
740
721
  errors.append(
741
722
  f"status is '{status.name}'. Detections that are enabled by default MUST be "
742
723
  f"'{DetectionStatus.production}'"
743
- )
724
+ )
744
725
 
745
- if searchType not in [AnalyticsType.Anomaly, AnalyticsType.Correlation, AnalyticsType.TTP]:
726
+ if searchType not in [
727
+ AnalyticsType.Anomaly,
728
+ AnalyticsType.Correlation,
729
+ AnalyticsType.TTP,
730
+ ]:
746
731
  errors.append(
747
732
  f"type is '{searchType}'. Detections that are enabled by default MUST be one"
748
733
  " of the following types: "
749
- f"{[AnalyticsType.Anomaly, AnalyticsType.Correlation, AnalyticsType.TTP]}")
734
+ f"{[AnalyticsType.Anomaly, AnalyticsType.Correlation, AnalyticsType.TTP]}"
735
+ )
750
736
  if len(errors) > 0:
751
737
  error_message = "\n - ".join(errors)
752
- raise ValueError(f"Detection is 'enabled_by_default: true' however \n - {error_message}")
738
+ raise ValueError(
739
+ f"Detection is 'enabled_by_default: true' however \n - {error_message}"
740
+ )
753
741
 
754
742
  return v
755
743
 
@@ -760,39 +748,42 @@ class Detection_Abstract(SecurityContentObject):
760
748
  else:
761
749
  self.tags.nist = [NistCategory.DE_AE]
762
750
  return self
763
-
764
751
 
765
752
  @model_validator(mode="after")
766
753
  def ensureThrottlingFieldsExist(self):
767
- '''
754
+ """
768
755
  For throttling to work properly, the fields to throttle on MUST
769
756
  exist in the search itself. If not, then we cannot apply the throttling
770
- '''
757
+ """
771
758
  if self.tags.throttling is None:
772
759
  # No throttling configured for this detection
773
760
  return self
774
761
 
775
- missing_fields:list[str] = [field for field in self.tags.throttling.fields if field not in self.search]
762
+ missing_fields: list[str] = [
763
+ field for field in self.tags.throttling.fields if field not in self.search
764
+ ]
776
765
  if len(missing_fields) > 0:
777
- raise ValueError(f"The following throttle fields were missing from the search: {missing_fields}")
766
+ raise ValueError(
767
+ f"The following throttle fields were missing from the search: {missing_fields}"
768
+ )
778
769
 
779
770
  else:
780
771
  # All throttling fields present in search
781
772
  return self
782
-
783
-
784
773
 
785
774
  @model_validator(mode="after")
786
775
  def ensureProperRBAConfig(self):
787
776
  """
788
777
  If a detection has an RBA deployment and is PRODUCTION, then it must have an RBA config, with at least one risk object
789
-
778
+
790
779
  Returns:
791
780
  self: Returns itself if the validation passes
792
781
  """
793
782
 
794
-
795
- if self.deployment.alert_action.rba is None or self.deployment.alert_action.rba.enabled is False:
783
+ if (
784
+ self.deployment.alert_action.rba is None
785
+ or self.deployment.alert_action.rba.enabled is False
786
+ ):
796
787
  # confirm we don't have an RBA config
797
788
  if self.rba is None:
798
789
  return self
@@ -806,55 +797,23 @@ class Detection_Abstract(SecurityContentObject):
806
797
  "Detection is expected to have an RBA object based on its deployment config"
807
798
  )
808
799
  else:
809
- if len(self.rba.risk_objects) > 0: # type: ignore
800
+ if len(self.rba.risk_objects) > 0: # type: ignore
810
801
  return self
811
802
  else:
812
803
  raise ValueError(
813
804
  "Detection expects an RBA config with at least one risk object."
814
805
  )
815
806
 
816
-
817
- # TODO - Remove old observable code
818
- # @model_validator(mode="after")
819
- # def ensureProperObservablesExist(self):
820
- # """
821
- # If a detections is PRODUCTION and either TTP or ANOMALY, then it MUST have an Observable with the VICTIM role.
822
-
823
- # Returns:
824
- # self: Returns itself if the valdiation passes
825
- # """
826
- # # NOTE: we ignore the type error around self.status because we are using Pydantic's
827
- # # use_enum_values configuration
828
- # # https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
829
- # if self.status not in [DetectionStatus.production.value]: # type: ignore
830
- # # Only perform this validation on production detections
831
- # return self
832
-
833
- # if self.type not in [AnalyticsType.TTP.value, AnalyticsType.Anomaly.value]:
834
- # # Only perform this validation on TTP and Anomaly detections
835
- # return self
836
-
837
- # # Detection is required to have a victim
838
- # roles: list[str] = []
839
- # for observable in self.tags.observable:
840
- # roles.extend(observable.role)
841
-
842
- # if roles.count("Victim") == 0:
843
- # raise ValueError(
844
- # "Error, there must be AT LEAST 1 Observable with the role 'Victim' declared in "
845
- # "Detection.tags.observables. However, none were found."
846
- # )
847
-
848
- # # Exactly one victim was found
849
- # return self
850
-
851
807
  @model_validator(mode="after")
852
808
  def search_rba_fields_exist_validate(self):
853
809
  # Return immediately if RBA isn't required
854
- if (self.deployment.alert_action.rba.enabled is False or self.deployment.alert_action.rba is None) and self.rba is None: #type: ignore
810
+ if (
811
+ self.deployment.alert_action.rba.enabled is False
812
+ or self.deployment.alert_action.rba is None
813
+ ) and self.rba is None: # type: ignore
855
814
  return self
856
-
857
- # Raise error if RBA isn't present
815
+
816
+ # Raise error if RBA isn't present
858
817
  if self.rba is None:
859
818
  raise ValueError(
860
819
  "RBA is required for this detection based on its deployment config"
@@ -869,7 +828,9 @@ class Detection_Abstract(SecurityContentObject):
869
828
  if self.rba.message:
870
829
  matches = re.findall(field_match_regex, self.rba.message.lower())
871
830
  message_fields = [match.replace("$", "").lower() for match in matches]
872
- missing_fields = set([field for field in rba_fields if field not in self.search.lower()])
831
+ missing_fields = set(
832
+ [field for field in rba_fields if field not in self.search.lower()]
833
+ )
873
834
  else:
874
835
  message_fields = []
875
836
  missing_fields = set()
@@ -880,15 +841,16 @@ class Detection_Abstract(SecurityContentObject):
880
841
  "The following fields are declared in the rba config, but do not exist in the "
881
842
  f"search: {missing_fields}"
882
843
  )
883
- missing_fields = set([field for field in message_fields if field not in self.search.lower()])
844
+ missing_fields = set(
845
+ [field for field in message_fields if field not in self.search.lower()]
846
+ )
884
847
  if len(missing_fields) > 0:
885
848
  error_messages.append(
886
849
  "The following fields are used as fields in the message, but do not exist in "
887
850
  f"the search: {missing_fields}"
888
851
  )
889
852
 
890
- if len(error_messages) > 0 and self.status == DetectionStatus.production:
891
-
853
+ if len(error_messages) > 0 and self.status == DetectionStatus.production:
892
854
  msg = (
893
855
  "Use of fields in rba/messages that do not appear in search:\n\t- "
894
856
  "\n\t- ".join(error_messages)
@@ -896,52 +858,8 @@ class Detection_Abstract(SecurityContentObject):
896
858
  raise ValueError(msg)
897
859
  return self
898
860
 
899
- # TODO: Remove old observable code
900
- # @model_validator(mode="after")
901
- # def search_observables_exist_validate(self):
902
- # observable_fields = [ob.name.lower() for ob in self.tags.observable]
903
-
904
- # # All $field$ fields from the message must appear in the search
905
- # field_match_regex = r"\$([^\s.]*)\$"
906
-
907
- # missing_fields: set[str]
908
- # if self.tags.message:
909
- # matches = re.findall(field_match_regex, self.tags.message.lower())
910
- # message_fields = [match.replace("$", "").lower() for match in matches]
911
- # missing_fields = set([field for field in observable_fields if field not in self.search.lower()])
912
- # else:
913
- # message_fields = []
914
- # missing_fields = set()
915
-
916
- # error_messages: list[str] = []
917
- # if len(missing_fields) > 0:
918
- # error_messages.append(
919
- # "The following fields are declared as observables, but do not exist in the "
920
- # f"search: {missing_fields}"
921
- # )
922
-
923
- # missing_fields = set([field for field in message_fields if field not in self.search.lower()])
924
- # if len(missing_fields) > 0:
925
- # error_messages.append(
926
- # "The following fields are used as fields in the message, but do not exist in "
927
- # f"the search: {missing_fields}"
928
- # )
929
-
930
- # # NOTE: we ignore the type error around self.status because we are using Pydantic's
931
- # # use_enum_values configuration
932
- # # https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
933
- # if len(error_messages) > 0 and self.status == DetectionStatus.production.value: # type: ignore
934
- # msg = (
935
- # "Use of fields in observables/messages that do not appear in search:\n\t- "
936
- # "\n\t- ".join(error_messages)
937
- # )
938
- # raise ValueError(msg)
939
-
940
- # # Found everything
941
- # return self
942
-
943
861
  @field_validator("tests", mode="before")
944
- def ensure_yml_test_is_unittest(cls, v:list[dict]):
862
+ def ensure_yml_test_is_unittest(cls, v: list[dict]):
945
863
  """The typing for the tests field allows it to be one of
946
864
  a number of different types of tests. However, ONLY
947
865
  UnitTest should be allowed to be defined in the YML
@@ -957,17 +875,17 @@ class Detection_Abstract(SecurityContentObject):
957
875
  it into a different type of test
958
876
 
959
877
  Args:
960
- v (list[dict]): list of dicts read from the yml.
878
+ v (list[dict]): list of dicts read from the yml.
961
879
  Each one SHOULD be a valid UnitTest. If we cannot
962
880
  construct a valid unitTest from it, a ValueError should be raised
963
881
 
964
882
  Returns:
965
- _type_: The input of the function, assuming no
883
+ _type_: The input of the function, assuming no
966
884
  ValueError is raised.
967
- """
968
- valueErrors:list[ValueError] = []
885
+ """
886
+ valueErrors: list[ValueError] = []
969
887
  for unitTest in v:
970
- #This raises a ValueError on a failed UnitTest.
888
+ # This raises a ValueError on a failed UnitTest.
971
889
  try:
972
890
  UnitTest.model_validate(unitTest)
973
891
  except ValueError as e:
@@ -977,13 +895,10 @@ class Detection_Abstract(SecurityContentObject):
977
895
  # All of these can be constructred as UnitTests with no
978
896
  # Exceptions, so let the normal flow continue
979
897
  return v
980
-
981
898
 
982
899
  @field_validator("tests")
983
900
  def tests_validate(
984
- cls,
985
- v: list[UnitTest | IntegrationTest | ManualTest],
986
- info: ValidationInfo
901
+ cls, v: list[UnitTest | IntegrationTest | ManualTest], info: ValidationInfo
987
902
  ) -> list[UnitTest | IntegrationTest | ManualTest]:
988
903
  # Only production analytics require tests
989
904
  if info.data.get("status", "") != DetectionStatus.production:
@@ -1003,7 +918,8 @@ class Detection_Abstract(SecurityContentObject):
1003
918
  # Ensure that there is at least 1 test
1004
919
  if len(v) == 0:
1005
920
  raise ValueError(
1006
- "At least one test is REQUIRED for production detection: " + info.data.get("name", "NO NAME FOUND")
921
+ "At least one test is REQUIRED for production detection: "
922
+ + info.data.get("name", "NO NAME FOUND")
1007
923
  )
1008
924
 
1009
925
  # No issues - at least one test provided for production type requiring testing
@@ -1075,13 +991,29 @@ class Detection_Abstract(SecurityContentObject):
1075
991
  def get_summary(
1076
992
  self,
1077
993
  detection_fields: list[str] = [
1078
- "name", "type", "status", "test_status", "source", "data_source", "search", "file_path"
994
+ "name",
995
+ "type",
996
+ "status",
997
+ "test_status",
998
+ "source",
999
+ "data_source",
1000
+ "search",
1001
+ "file_path",
1079
1002
  ],
1080
1003
  detection_field_aliases: dict[str, str] = {
1081
- "status": "production_status", "test_status": "status", "source": "source_category"
1004
+ "status": "production_status",
1005
+ "test_status": "status",
1006
+ "source": "source_category",
1082
1007
  },
1083
1008
  tags_fields: list[str] = ["manual_test"],
1084
- test_result_fields: list[str] = ["success", "message", "exception", "status", "duration", "wait_duration"],
1009
+ test_result_fields: list[str] = [
1010
+ "success",
1011
+ "message",
1012
+ "exception",
1013
+ "status",
1014
+ "duration",
1015
+ "wait_duration",
1016
+ ],
1085
1017
  test_job_fields: list[str] = ["resultCount", "runDuration"],
1086
1018
  ) -> dict[str, Any]:
1087
1019
  """
@@ -1121,7 +1053,7 @@ class Detection_Abstract(SecurityContentObject):
1121
1053
  # Initialize the dict as a mapping of strings to str/bool
1122
1054
  result: dict[str, Union[str, bool]] = {
1123
1055
  "name": test.name,
1124
- "test_type": test.test_type
1056
+ "test_type": test.test_type,
1125
1057
  }
1126
1058
 
1127
1059
  # If result is not None, get a summary of the test result w/ the requested fields
@@ -1138,7 +1070,7 @@ class Detection_Abstract(SecurityContentObject):
1138
1070
  result["message"] = "NO RESULT - Test not run"
1139
1071
 
1140
1072
  # Add the result to our list
1141
- summary_dict["tests"].append(result) # type: ignore
1073
+ summary_dict["tests"].append(result) # type: ignore
1142
1074
 
1143
1075
  # Return the summary
1144
1076