contentctl 5.0.0a2__py3-none-any.whl → 5.0.0a3__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 (106) 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 +78 -50
  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 +250 -314
  34. contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +58 -36
  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 +41 -30
  41. contentctl/objects/baseline_tags.py +29 -22
  42. contentctl/objects/config.py +1 -1
  43. contentctl/objects/constants.py +29 -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 +41 -26
  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 +14 -8
  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 +45 -44
  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/transforms.j2 +2 -2
  98. contentctl/output/yml_writer.py +18 -24
  99. {contentctl-5.0.0a2.dist-info → contentctl-5.0.0a3.dist-info}/METADATA +1 -1
  100. contentctl-5.0.0a3.dist-info/RECORD +168 -0
  101. contentctl/actions/initialize_old.py +0 -245
  102. contentctl/objects/observable.py +0 -39
  103. contentctl-5.0.0a2.dist-info/RECORD +0 -170
  104. {contentctl-5.0.0a2.dist-info → contentctl-5.0.0a3.dist-info}/LICENSE.md +0 -0
  105. {contentctl-5.0.0a2.dist-info → contentctl-5.0.0a3.dist-info}/WHEEL +0 -0
  106. {contentctl-5.0.0a2.dist-info → contentctl-5.0.0a3.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
25
 
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
-
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,7 +380,6 @@ 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]]:
@@ -370,83 +387,21 @@ class Detection_Abstract(SecurityContentObject):
370
387
 
371
388
  for entity in self.rba.risk_objects:
372
389
  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
390
+ risk_object["risk_object_type"] = entity.type
391
+ risk_object["risk_object_field"] = entity.field
392
+ risk_object["risk_score"] = entity.score
376
393
  risk_objects.append(risk_object)
377
-
394
+
378
395
  for entity in self.rba.threat_objects:
379
396
  threat_object: dict[str, str] = dict()
380
- threat_object['threat_object_field'] = entity.field
381
- threat_object['threat_object_type'] = entity.type
397
+ threat_object["threat_object_field"] = entity.field
398
+ threat_object["threat_object_type"] = entity.type
382
399
  risk_objects.append(threat_object)
383
400
  return risk_objects
384
401
 
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
446
-
447
402
  @computed_field
448
403
  @property
449
- def metadata(self) -> dict[str, str|float]:
404
+ def metadata(self) -> dict[str, str | float]:
450
405
  # NOTE: we ignore the type error around self.status because we are using Pydantic's
451
406
  # use_enum_values configuration
452
407
  # https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
@@ -456,10 +411,19 @@ class Detection_Abstract(SecurityContentObject):
456
411
  # dict below) should not have any impact, but renaming or removing any of these fields will
457
412
  # break the `inspect` action.
458
413
  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()
414
+ "detection_id": str(self.id),
415
+ "deprecated": "1" if self.status == DetectionStatus.deprecated else "0", # type: ignore
416
+ "detection_version": str(self.version),
417
+ "publish_time": datetime.datetime(
418
+ self.date.year,
419
+ self.date.month,
420
+ self.date.day,
421
+ 0,
422
+ 0,
423
+ 0,
424
+ 0,
425
+ tzinfo=datetime.timezone.utc,
426
+ ).timestamp(),
463
427
  }
464
428
 
465
429
  @model_serializer
@@ -480,9 +444,9 @@ class Detection_Abstract(SecurityContentObject):
480
444
  }
481
445
  if self.rba is not None:
482
446
  model["risk_severity"] = self.rba.severity
483
- model['tags']['risk_score'] = self.rba.risk_score
447
+ model["tags"]["risk_score"] = self.rba.risk_score
484
448
  else:
485
- model['tags']['risk_score'] = 0
449
+ model["tags"]["risk_score"] = 0
486
450
 
487
451
  # Only a subset of macro fields are required:
488
452
  all_macros: list[dict[str, str | list[str]]] = []
@@ -490,13 +454,13 @@ class Detection_Abstract(SecurityContentObject):
490
454
  macro_dump: dict[str, str | list[str]] = {
491
455
  "name": macro.name,
492
456
  "definition": macro.definition,
493
- "description": macro.description
457
+ "description": macro.description,
494
458
  }
495
459
  if len(macro.arguments) > 0:
496
- macro_dump['arguments'] = macro.arguments
460
+ macro_dump["arguments"] = macro.arguments
497
461
 
498
462
  all_macros.append(macro_dump)
499
- model['macros'] = all_macros # type: ignore
463
+ model["macros"] = all_macros # type: ignore
500
464
 
501
465
  all_lookups: list[dict[str, str | int | None]] = []
502
466
  for lookup in self.lookups:
@@ -507,7 +471,7 @@ class Detection_Abstract(SecurityContentObject):
507
471
  "description": lookup.description,
508
472
  "collection": lookup.collection,
509
473
  "case_sensitive_match": None,
510
- "fields_list": lookup.fields_to_fields_list_conf_format
474
+ "fields_list": lookup.fields_to_fields_list_conf_format,
511
475
  }
512
476
  )
513
477
  elif isinstance(lookup, FileBackedLookup):
@@ -517,15 +481,17 @@ class Detection_Abstract(SecurityContentObject):
517
481
  "description": lookup.description,
518
482
  "filename": lookup.filename.name,
519
483
  "default_match": "true" if lookup.default_match else "false",
520
- "case_sensitive_match": "true" if lookup.case_sensitive_match else "false",
484
+ "case_sensitive_match": "true"
485
+ if lookup.case_sensitive_match
486
+ else "false",
521
487
  "match_type": lookup.match_type_to_conf_format,
522
- "min_matches": lookup.min_matches
488
+ "min_matches": lookup.min_matches,
523
489
  }
524
490
  )
525
- model['lookups'] = all_lookups # type: ignore
491
+ model["lookups"] = all_lookups # type: ignore
526
492
 
527
493
  # Combine fields from this model with fields from parent
528
- super_fields.update(model) # type: ignore
494
+ super_fields.update(model) # type: ignore
529
495
 
530
496
  # return the model
531
497
  return super_fields
@@ -558,7 +524,7 @@ class Detection_Abstract(SecurityContentObject):
558
524
  updated_data_source_names: set[str] = set()
559
525
 
560
526
  for ds in self.data_source:
561
- split_data_sources = {d.strip() for d in ds.split('AND')}
527
+ split_data_sources = {d.strip() for d in ds.split("AND")}
562
528
  updated_data_source_names.update(split_data_sources)
563
529
 
564
530
  sources = sorted(list(updated_data_source_names))
@@ -567,7 +533,9 @@ class Detection_Abstract(SecurityContentObject):
567
533
  missing_sources: list[str] = []
568
534
  for source in sources:
569
535
  try:
570
- matched_data_sources += DataSource.mapNamesToSecurityContentObjects([source], director)
536
+ matched_data_sources += DataSource.mapNamesToSecurityContentObjects(
537
+ [source], director
538
+ )
571
539
  except Exception:
572
540
  # We gobble this up and add it to a global set so that we
573
541
  # can print it ONCE at the end of the build of datasources.
@@ -584,7 +552,7 @@ class Detection_Abstract(SecurityContentObject):
584
552
  self.data_source_objects = matched_data_sources
585
553
 
586
554
  for story in self.tags.analytic_story:
587
- story.detections.append(self)
555
+ story.detections.append(self)
588
556
 
589
557
  self.cve_enrichment_func(__context)
590
558
 
@@ -595,32 +563,39 @@ class Detection_Abstract(SecurityContentObject):
595
563
  # 1 of the drilldowns contains the string Drilldown.SEARCH_PLACEHOLDER.
596
564
  # This is presently a requirement when 1 or more drilldowns are added to a detection.
597
565
  # 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.
566
+
567
+ if (
568
+ self.type == AnalyticsType.Hunting
569
+ or self.status != DetectionStatus.production
570
+ ):
571
+ # No additional check need to happen on the potential drilldowns.
601
572
  pass
602
573
  else:
603
574
  found_placeholder = False
604
575
  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)}]")
576
+ raise ValueError(
577
+ f"This detection is required to have 2 drilldown_searches, but only has [{len(self.drilldown_searches)}]"
578
+ )
606
579
  for drilldown in self.drilldown_searches:
607
580
  if DRILLDOWN_SEARCH_PLACEHOLDER in drilldown.search:
608
581
  found_placeholder = True
609
582
  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
-
583
+ raise ValueError(
584
+ "Detection has one or more drilldown_searches, but none of them "
585
+ f"contained '{DRILLDOWN_SEARCH_PLACEHOLDER}. This is a requirement "
586
+ "if drilldown_searches are defined.'"
587
+ )
588
+
614
589
  # Update the search fields with the original search, if required
615
590
  for drilldown in self.drilldown_searches:
616
591
  drilldown.perform_search_substitutions(self)
617
592
 
618
- #For experimental purposes, add the default drilldowns
619
- #self.drilldown_searches.extend(Drilldown.constructDrilldownsFromDetection(self))
593
+ # For experimental purposes, add the default drilldowns
594
+ # self.drilldown_searches.extend(Drilldown.constructDrilldownsFromDetection(self))
620
595
 
621
596
  @property
622
- def drilldowns_in_JSON(self) -> list[dict[str,str]]:
623
- """This function is required for proper JSON
597
+ def drilldowns_in_JSON(self) -> list[dict[str, str]]:
598
+ """This function is required for proper JSON
624
599
  serializiation of drilldowns to occur in savedsearches.conf.
625
600
  It returns the list[Drilldown] as a list[dict].
626
601
  Without this function, the jinja template is unable
@@ -628,24 +603,26 @@ class Detection_Abstract(SecurityContentObject):
628
603
 
629
604
  Returns:
630
605
  list[dict[str,str]]: List of Drilldowns dumped to dict format
631
- """
606
+ """
632
607
  return [drilldown.model_dump() for drilldown in self.drilldown_searches]
633
608
 
634
- @field_validator('lookups', mode="before")
609
+ @field_validator("lookups", mode="before")
635
610
  @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)
611
+ def getDetectionLookups(cls, v: list[str], info: ValidationInfo) -> list[Lookup]:
612
+ director: DirectorOutputDto = info.context.get("output_dto", None)
613
+
614
+ search: Union[str, None] = info.data.get("search", None)
640
615
  if search is None:
641
616
  raise ValueError("Search was None - is this file missing the search field?")
642
-
617
+
643
618
  lookups = Lookup.get_lookups(search, director)
644
619
  return lookups
645
620
 
646
- @field_validator('baselines', mode="before")
621
+ @field_validator("baselines", mode="before")
647
622
  @classmethod
648
- def mapDetectionNamesToBaselineObjects(cls, v: list[str], info: ValidationInfo) -> List[Baseline]:
623
+ def mapDetectionNamesToBaselineObjects(
624
+ cls, v: list[str], info: ValidationInfo
625
+ ) -> List[Baseline]:
649
626
  if len(v) > 0:
650
627
  raise ValueError(
651
628
  "Error, baselines are constructed automatically at runtime. Please do not include this field."
@@ -653,7 +630,9 @@ class Detection_Abstract(SecurityContentObject):
653
630
 
654
631
  name: Union[str, None] = info.data.get("name", None)
655
632
  if name is None:
656
- raise ValueError("Error, cannot get Baselines because the Detection does not have a 'name' defined.")
633
+ raise ValueError(
634
+ "Error, cannot get Baselines because the Detection does not have a 'name' defined."
635
+ )
657
636
 
658
637
  if info.context is None:
659
638
  raise ValueError("ValidationInfo.context unexpectedly null")
@@ -664,14 +643,16 @@ class Detection_Abstract(SecurityContentObject):
664
643
  # This matching is a bit strange, because baseline.tags.detections starts as a list of strings, but
665
644
  # is eventually updated to a list of Detections as we construct all of the detection objects.
666
645
  detection_names = [
667
- detection_name for detection_name in baseline.tags.detections if isinstance(detection_name, str)
646
+ detection_name
647
+ for detection_name in baseline.tags.detections
648
+ if isinstance(detection_name, str)
668
649
  ]
669
650
  if name in detection_names:
670
651
  baselines.append(baseline)
671
652
 
672
653
  return baselines
673
654
 
674
- @field_validator('macros', mode="before")
655
+ @field_validator("macros", mode="before")
675
656
  @classmethod
676
657
  def getDetectionMacros(cls, v: list[str], info: ValidationInfo) -> list[Macro]:
677
658
  if info.context is None:
@@ -687,21 +668,25 @@ class Detection_Abstract(SecurityContentObject):
687
668
  message = f"Expected 'search_name' to be a string, instead it was [{type(search_name)}]"
688
669
  assert isinstance(search_name, str), message
689
670
 
690
- filter_macro_name = search_name.replace(' ', '_')\
691
- .replace('-', '_')\
692
- .replace('.', '_')\
693
- .replace('/', '_')\
694
- .lower()\
695
- + '_filter'
671
+ filter_macro_name = (
672
+ search_name.replace(" ", "_")
673
+ .replace("-", "_")
674
+ .replace(".", "_")
675
+ .replace("/", "_")
676
+ .lower()
677
+ + "_filter"
678
+ )
696
679
  try:
697
- filter_macro = Macro.mapNamesToSecurityContentObjects([filter_macro_name], director)[0]
680
+ filter_macro = Macro.mapNamesToSecurityContentObjects(
681
+ [filter_macro_name], director
682
+ )[0]
698
683
  except Exception:
699
684
  # Filter macro did not exist, so create one at runtime
700
685
  filter_macro = Macro.model_validate(
701
686
  {
702
687
  "name": filter_macro_name,
703
- "definition": 'search *',
704
- "description": 'Update this macro to limit the output results to filter out false positives.'
688
+ "definition": "search *",
689
+ "description": "Update this macro to limit the output results to filter out false positives.",
705
690
  }
706
691
  )
707
692
  director.addContentToDictMappings(filter_macro)
@@ -724,12 +709,12 @@ class Detection_Abstract(SecurityContentObject):
724
709
 
725
710
  @field_validator("enabled_by_default", mode="before")
726
711
  def only_enabled_if_production_status(cls, v: Any, info: ValidationInfo) -> bool:
727
- '''
712
+ """
728
713
  A detection can ONLY be enabled by default if it is a PRODUCTION detection.
729
714
  If not (for example, it is EXPERIMENTAL or DEPRECATED) then we will throw an exception.
730
715
  Similarly, a detection MUST be schedulable, meaning that it must be Anomaly, Correleation, or TTP.
731
716
  We will not allow Hunting searches to be enabled by default.
732
- '''
717
+ """
733
718
  if v is False:
734
719
  return v
735
720
 
@@ -740,16 +725,23 @@ class Detection_Abstract(SecurityContentObject):
740
725
  errors.append(
741
726
  f"status is '{status.name}'. Detections that are enabled by default MUST be "
742
727
  f"'{DetectionStatus.production}'"
743
- )
728
+ )
744
729
 
745
- if searchType not in [AnalyticsType.Anomaly, AnalyticsType.Correlation, AnalyticsType.TTP]:
730
+ if searchType not in [
731
+ AnalyticsType.Anomaly,
732
+ AnalyticsType.Correlation,
733
+ AnalyticsType.TTP,
734
+ ]:
746
735
  errors.append(
747
736
  f"type is '{searchType}'. Detections that are enabled by default MUST be one"
748
737
  " of the following types: "
749
- f"{[AnalyticsType.Anomaly, AnalyticsType.Correlation, AnalyticsType.TTP]}")
738
+ f"{[AnalyticsType.Anomaly, AnalyticsType.Correlation, AnalyticsType.TTP]}"
739
+ )
750
740
  if len(errors) > 0:
751
741
  error_message = "\n - ".join(errors)
752
- raise ValueError(f"Detection is 'enabled_by_default: true' however \n - {error_message}")
742
+ raise ValueError(
743
+ f"Detection is 'enabled_by_default: true' however \n - {error_message}"
744
+ )
753
745
 
754
746
  return v
755
747
 
@@ -760,39 +752,42 @@ class Detection_Abstract(SecurityContentObject):
760
752
  else:
761
753
  self.tags.nist = [NistCategory.DE_AE]
762
754
  return self
763
-
764
755
 
765
756
  @model_validator(mode="after")
766
757
  def ensureThrottlingFieldsExist(self):
767
- '''
758
+ """
768
759
  For throttling to work properly, the fields to throttle on MUST
769
760
  exist in the search itself. If not, then we cannot apply the throttling
770
- '''
761
+ """
771
762
  if self.tags.throttling is None:
772
763
  # No throttling configured for this detection
773
764
  return self
774
765
 
775
- missing_fields:list[str] = [field for field in self.tags.throttling.fields if field not in self.search]
766
+ missing_fields: list[str] = [
767
+ field for field in self.tags.throttling.fields if field not in self.search
768
+ ]
776
769
  if len(missing_fields) > 0:
777
- raise ValueError(f"The following throttle fields were missing from the search: {missing_fields}")
770
+ raise ValueError(
771
+ f"The following throttle fields were missing from the search: {missing_fields}"
772
+ )
778
773
 
779
774
  else:
780
775
  # All throttling fields present in search
781
776
  return self
782
-
783
-
784
777
 
785
778
  @model_validator(mode="after")
786
779
  def ensureProperRBAConfig(self):
787
780
  """
788
781
  If a detection has an RBA deployment and is PRODUCTION, then it must have an RBA config, with at least one risk object
789
-
782
+
790
783
  Returns:
791
784
  self: Returns itself if the validation passes
792
785
  """
793
786
 
794
-
795
- if self.deployment.alert_action.rba is None or self.deployment.alert_action.rba.enabled is False:
787
+ if (
788
+ self.deployment.alert_action.rba is None
789
+ or self.deployment.alert_action.rba.enabled is False
790
+ ):
796
791
  # confirm we don't have an RBA config
797
792
  if self.rba is None:
798
793
  return self
@@ -806,55 +801,23 @@ class Detection_Abstract(SecurityContentObject):
806
801
  "Detection is expected to have an RBA object based on its deployment config"
807
802
  )
808
803
  else:
809
- if len(self.rba.risk_objects) > 0: # type: ignore
804
+ if len(self.rba.risk_objects) > 0: # type: ignore
810
805
  return self
811
806
  else:
812
807
  raise ValueError(
813
808
  "Detection expects an RBA config with at least one risk object."
814
809
  )
815
810
 
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
811
  @model_validator(mode="after")
852
812
  def search_rba_fields_exist_validate(self):
853
813
  # 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
814
+ if (
815
+ self.deployment.alert_action.rba.enabled is False
816
+ or self.deployment.alert_action.rba is None
817
+ ) and self.rba is None: # type: ignore
855
818
  return self
856
-
857
- # Raise error if RBA isn't present
819
+
820
+ # Raise error if RBA isn't present
858
821
  if self.rba is None:
859
822
  raise ValueError(
860
823
  "RBA is required for this detection based on its deployment config"
@@ -869,7 +832,9 @@ class Detection_Abstract(SecurityContentObject):
869
832
  if self.rba.message:
870
833
  matches = re.findall(field_match_regex, self.rba.message.lower())
871
834
  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()])
835
+ missing_fields = set(
836
+ [field for field in rba_fields if field not in self.search.lower()]
837
+ )
873
838
  else:
874
839
  message_fields = []
875
840
  missing_fields = set()
@@ -880,15 +845,16 @@ class Detection_Abstract(SecurityContentObject):
880
845
  "The following fields are declared in the rba config, but do not exist in the "
881
846
  f"search: {missing_fields}"
882
847
  )
883
- missing_fields = set([field for field in message_fields if field not in self.search.lower()])
848
+ missing_fields = set(
849
+ [field for field in message_fields if field not in self.search.lower()]
850
+ )
884
851
  if len(missing_fields) > 0:
885
852
  error_messages.append(
886
853
  "The following fields are used as fields in the message, but do not exist in "
887
854
  f"the search: {missing_fields}"
888
855
  )
889
856
 
890
- if len(error_messages) > 0 and self.status == DetectionStatus.production:
891
-
857
+ if len(error_messages) > 0 and self.status == DetectionStatus.production:
892
858
  msg = (
893
859
  "Use of fields in rba/messages that do not appear in search:\n\t- "
894
860
  "\n\t- ".join(error_messages)
@@ -896,52 +862,8 @@ class Detection_Abstract(SecurityContentObject):
896
862
  raise ValueError(msg)
897
863
  return self
898
864
 
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
865
  @field_validator("tests", mode="before")
944
- def ensure_yml_test_is_unittest(cls, v:list[dict]):
866
+ def ensure_yml_test_is_unittest(cls, v: list[dict]):
945
867
  """The typing for the tests field allows it to be one of
946
868
  a number of different types of tests. However, ONLY
947
869
  UnitTest should be allowed to be defined in the YML
@@ -957,17 +879,17 @@ class Detection_Abstract(SecurityContentObject):
957
879
  it into a different type of test
958
880
 
959
881
  Args:
960
- v (list[dict]): list of dicts read from the yml.
882
+ v (list[dict]): list of dicts read from the yml.
961
883
  Each one SHOULD be a valid UnitTest. If we cannot
962
884
  construct a valid unitTest from it, a ValueError should be raised
963
885
 
964
886
  Returns:
965
- _type_: The input of the function, assuming no
887
+ _type_: The input of the function, assuming no
966
888
  ValueError is raised.
967
- """
968
- valueErrors:list[ValueError] = []
889
+ """
890
+ valueErrors: list[ValueError] = []
969
891
  for unitTest in v:
970
- #This raises a ValueError on a failed UnitTest.
892
+ # This raises a ValueError on a failed UnitTest.
971
893
  try:
972
894
  UnitTest.model_validate(unitTest)
973
895
  except ValueError as e:
@@ -977,13 +899,10 @@ class Detection_Abstract(SecurityContentObject):
977
899
  # All of these can be constructred as UnitTests with no
978
900
  # Exceptions, so let the normal flow continue
979
901
  return v
980
-
981
902
 
982
903
  @field_validator("tests")
983
904
  def tests_validate(
984
- cls,
985
- v: list[UnitTest | IntegrationTest | ManualTest],
986
- info: ValidationInfo
905
+ cls, v: list[UnitTest | IntegrationTest | ManualTest], info: ValidationInfo
987
906
  ) -> list[UnitTest | IntegrationTest | ManualTest]:
988
907
  # Only production analytics require tests
989
908
  if info.data.get("status", "") != DetectionStatus.production:
@@ -1003,7 +922,8 @@ class Detection_Abstract(SecurityContentObject):
1003
922
  # Ensure that there is at least 1 test
1004
923
  if len(v) == 0:
1005
924
  raise ValueError(
1006
- "At least one test is REQUIRED for production detection: " + info.data.get("name", "NO NAME FOUND")
925
+ "At least one test is REQUIRED for production detection: "
926
+ + info.data.get("name", "NO NAME FOUND")
1007
927
  )
1008
928
 
1009
929
  # No issues - at least one test provided for production type requiring testing
@@ -1075,13 +995,29 @@ class Detection_Abstract(SecurityContentObject):
1075
995
  def get_summary(
1076
996
  self,
1077
997
  detection_fields: list[str] = [
1078
- "name", "type", "status", "test_status", "source", "data_source", "search", "file_path"
998
+ "name",
999
+ "type",
1000
+ "status",
1001
+ "test_status",
1002
+ "source",
1003
+ "data_source",
1004
+ "search",
1005
+ "file_path",
1079
1006
  ],
1080
1007
  detection_field_aliases: dict[str, str] = {
1081
- "status": "production_status", "test_status": "status", "source": "source_category"
1008
+ "status": "production_status",
1009
+ "test_status": "status",
1010
+ "source": "source_category",
1082
1011
  },
1083
1012
  tags_fields: list[str] = ["manual_test"],
1084
- test_result_fields: list[str] = ["success", "message", "exception", "status", "duration", "wait_duration"],
1013
+ test_result_fields: list[str] = [
1014
+ "success",
1015
+ "message",
1016
+ "exception",
1017
+ "status",
1018
+ "duration",
1019
+ "wait_duration",
1020
+ ],
1085
1021
  test_job_fields: list[str] = ["resultCount", "runDuration"],
1086
1022
  ) -> dict[str, Any]:
1087
1023
  """
@@ -1121,7 +1057,7 @@ class Detection_Abstract(SecurityContentObject):
1121
1057
  # Initialize the dict as a mapping of strings to str/bool
1122
1058
  result: dict[str, Union[str, bool]] = {
1123
1059
  "name": test.name,
1124
- "test_type": test.test_type
1060
+ "test_type": test.test_type,
1125
1061
  }
1126
1062
 
1127
1063
  # If result is not None, get a summary of the test result w/ the requested fields
@@ -1138,7 +1074,7 @@ class Detection_Abstract(SecurityContentObject):
1138
1074
  result["message"] = "NO RESULT - Test not run"
1139
1075
 
1140
1076
  # Add the result to our list
1141
- summary_dict["tests"].append(result) # type: ignore
1077
+ summary_dict["tests"].append(result) # type: ignore
1142
1078
 
1143
1079
  # Return the summary
1144
1080