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,50 +1,92 @@
1
1
  from __future__ import annotations
2
- from typing import TYPE_CHECKING, Self, Any
2
+
3
+ from typing import TYPE_CHECKING, Any, Self
3
4
 
4
5
  if TYPE_CHECKING:
6
+ from contentctl.input.director import DirectorOutputDto
5
7
  from contentctl.objects.deployment import Deployment
6
8
  from contentctl.objects.security_content_object import SecurityContentObject
7
- from contentctl.input.director import DirectorOutputDto
8
- from contentctl.objects.config import CustomApp
9
9
 
10
- from contentctl.objects.enums import AnalyticsType
11
- from contentctl.objects.constants import CONTENTCTL_MAX_STANZA_LENGTH
12
10
  import abc
13
- import uuid
14
11
  import datetime
12
+ import pathlib
15
13
  import pprint
14
+ import uuid
15
+ from functools import cached_property
16
+ from typing import List, Optional, Tuple, Union
17
+
16
18
  from pydantic import (
17
19
  BaseModel,
18
- field_validator,
20
+ ConfigDict,
19
21
  Field,
20
- ValidationInfo,
21
22
  FilePath,
22
23
  HttpUrl,
23
24
  NonNegativeInt,
24
- ConfigDict,
25
- model_serializer
25
+ ValidationInfo,
26
+ computed_field,
27
+ field_validator,
28
+ model_serializer,
26
29
  )
27
- from typing import Tuple, Optional, List, Union
28
- import pathlib
29
30
 
31
+ from contentctl.objects.constants import (
32
+ CONTENTCTL_MAX_STANZA_LENGTH,
33
+ DEPRECATED_TEMPLATE,
34
+ EXPERIMENTAL_TEMPLATE,
35
+ )
36
+ from contentctl.objects.enums import AnalyticsType, DetectionStatus
30
37
 
31
38
  NO_FILE_NAME = "NO_FILE_NAME"
32
39
 
33
40
 
34
41
  class SecurityContentObject_Abstract(BaseModel, abc.ABC):
35
- model_config = ConfigDict(validate_default=True,extra="forbid")
36
- name: str = Field(...,max_length=99)
37
- author: str = Field(...,max_length=255)
42
+ model_config = ConfigDict(validate_default=True, extra="forbid")
43
+ name: str = Field(..., max_length=99)
44
+ author: str = Field(..., max_length=255)
38
45
  date: datetime.date = Field(...)
39
46
  version: NonNegativeInt = Field(...)
40
- id: uuid.UUID = Field(...) #we set a default here until all content has a uuid
41
- description: str = Field(...,max_length=10000)
47
+ id: uuid.UUID = Field(...) # we set a default here until all content has a uuid
48
+ description: str = Field(..., max_length=10000)
42
49
  file_path: Optional[FilePath] = None
43
50
  references: Optional[List[HttpUrl]] = None
44
51
 
45
52
  def model_post_init(self, __context: Any) -> None:
46
53
  self.ensureFileNameMatchesSearchName()
47
54
 
55
+ @computed_field
56
+ @cached_property
57
+ def status_aware_description(self) -> str:
58
+ """We need to be able to write out a description that includes information
59
+ about whether or not a detection has been deprecated or not. This is important
60
+ for providing information to the user as well as powering the deprecation
61
+ assistant dashboad(s). Make sure this information is output correctly, if
62
+ appropriate.
63
+ Otherwise, if a detection is not deprecated or experimental, just return th
64
+ unmodified description.
65
+
66
+ Raises:
67
+ NotImplementedError: This content type does not support status_aware_description.
68
+ This is because the object does not define a status field
69
+
70
+ Returns:
71
+ str: description, which may or may not be prefixed with the deprecation/experimental message
72
+ """
73
+ status = getattr(self, "status", None)
74
+
75
+ if not isinstance(status, DetectionStatus):
76
+ raise NotImplementedError(
77
+ f"Detection status is not implemented for [{self.name}] of type '{type(self).__name__}'"
78
+ )
79
+ if status == DetectionStatus.experimental:
80
+ return EXPERIMENTAL_TEMPLATE.format(
81
+ content_type=type(self).__name__, description=self.description
82
+ )
83
+ elif status == DetectionStatus.deprecated:
84
+ return DEPRECATED_TEMPLATE.format(
85
+ content_type=type(self).__name__, description=self.description
86
+ )
87
+ else:
88
+ return self.description
89
+
48
90
  @model_serializer
49
91
  def serialize_model(self):
50
92
  return {
@@ -54,15 +96,18 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
54
96
  "version": self.version,
55
97
  "id": str(self.id),
56
98
  "description": self.description,
57
- "references": [str(url) for url in self.references or []]
99
+ "references": [str(url) for url in self.references or []],
58
100
  }
59
-
60
-
61
- def check_conf_stanza_max_length(self, stanza_name:str, max_stanza_length:int=CONTENTCTL_MAX_STANZA_LENGTH) -> None:
101
+
102
+ def check_conf_stanza_max_length(
103
+ self, stanza_name: str, max_stanza_length: int = CONTENTCTL_MAX_STANZA_LENGTH
104
+ ) -> None:
62
105
  if len(stanza_name) > max_stanza_length:
63
- raise ValueError(f"conf stanza may only be {max_stanza_length} characters, "
64
- f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' ")
65
-
106
+ raise ValueError(
107
+ f"conf stanza may only be {max_stanza_length} characters, "
108
+ f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' "
109
+ )
110
+
66
111
  @staticmethod
67
112
  def objectListToNameList(objects: list[SecurityContentObject]) -> list[str]:
68
113
  return [object.getName() for object in objects]
@@ -74,17 +119,19 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
74
119
 
75
120
  @classmethod
76
121
  def contentNameToFileName(cls, content_name: str) -> str:
77
- return content_name \
78
- .replace(' ', '_') \
79
- .replace('-', '_') \
80
- .replace('.', '_') \
81
- .replace('/', '_') \
82
- .lower() + ".yml"
122
+ return (
123
+ content_name.replace(" ", "_")
124
+ .replace("-", "_")
125
+ .replace(".", "_")
126
+ .replace("/", "_")
127
+ .lower()
128
+ + ".yml"
129
+ )
83
130
 
84
131
  def ensureFileNameMatchesSearchName(self):
85
132
  file_name = self.contentNameToFileName(self.name)
86
133
 
87
- if (self.file_path is not None and file_name != self.file_path.name):
134
+ if self.file_path is not None and file_name != self.file_path.name:
88
135
  raise ValueError(
89
136
  f"The file name MUST be based off the content 'name' field:\n"
90
137
  f"\t- Expected File Name: {file_name}\n"
@@ -93,7 +140,7 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
93
140
 
94
141
  return self
95
142
 
96
- @field_validator('file_path')
143
+ @field_validator("file_path")
97
144
  @classmethod
98
145
  def file_path_valid(cls, v: Optional[pathlib.PosixPath], info: ValidationInfo):
99
146
  if not v:
@@ -110,7 +157,9 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
110
157
  return [str(url) for url in self.references or []]
111
158
 
112
159
  @classmethod
113
- def mapNamesToSecurityContentObjects(cls, v: list[str], director: Union[DirectorOutputDto, None]) -> list[Self]:
160
+ def mapNamesToSecurityContentObjects(
161
+ cls, v: list[str], director: Union[DirectorOutputDto, None]
162
+ ) -> list[Self]:
114
163
  if director is not None:
115
164
  name_map = director.name_to_content_map
116
165
  else:
@@ -130,7 +179,9 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
130
179
 
131
180
  errors: list[str] = []
132
181
  if len(missing_objects) > 0:
133
- errors.append(f"Failed to find the following '{cls.__name__}': {missing_objects}")
182
+ errors.append(
183
+ f"Failed to find the following '{cls.__name__}': {missing_objects}"
184
+ )
134
185
  if len(mistyped_objects) > 0:
135
186
  for mistyped_object in mistyped_objects:
136
187
  errors.append(
@@ -142,13 +193,16 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
142
193
  error_string = "\n - ".join(errors)
143
194
  raise ValueError(
144
195
  f"Found {len(errors)} issues when resolving references Security Content Object "
145
- f"names:\n - {error_string}")
196
+ f"names:\n - {error_string}"
197
+ )
146
198
 
147
199
  # Sort all objects sorted by name
148
200
  return sorted(mappedObjects, key=lambda o: o.name)
149
201
 
150
202
  @staticmethod
151
- def getDeploymentFromType(typeField: Union[str, None], info: ValidationInfo) -> Deployment:
203
+ def getDeploymentFromType(
204
+ typeField: Union[str, None], info: ValidationInfo
205
+ ) -> Deployment:
152
206
  if typeField is None:
153
207
  raise ValueError("'type:' field is missing from YML.")
154
208
 
@@ -157,21 +211,25 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
157
211
 
158
212
  director: Optional[DirectorOutputDto] = info.context.get("output_dto", None)
159
213
  if not director:
160
- raise ValueError("Cannot set deployment - DirectorOutputDto not passed to Detection Constructor in context")
214
+ raise ValueError(
215
+ "Cannot set deployment - DirectorOutputDto not passed to Detection Constructor in context"
216
+ )
161
217
 
162
218
  type_to_deployment_name_map = {
163
219
  AnalyticsType.TTP: "ESCU Default Configuration TTP",
164
220
  AnalyticsType.Hunting: "ESCU Default Configuration Hunting",
165
221
  AnalyticsType.Correlation: "ESCU Default Configuration Correlation",
166
222
  AnalyticsType.Anomaly: "ESCU Default Configuration Anomaly",
167
- "Baseline": "ESCU Default Configuration Baseline"
223
+ "Baseline": "ESCU Default Configuration Baseline",
168
224
  }
169
225
  converted_type_field = type_to_deployment_name_map[typeField]
170
226
 
171
227
  # TODO: This is clunky, but is imported here to resolve some circular import errors
172
228
  from contentctl.objects.deployment import Deployment
173
229
 
174
- deployments = Deployment.mapNamesToSecurityContentObjects([converted_type_field], director)
230
+ deployments = Deployment.mapNamesToSecurityContentObjects(
231
+ [converted_type_field], director
232
+ )
175
233
  if len(deployments) == 1:
176
234
  return deployments[0]
177
235
  elif len(deployments) == 0:
@@ -187,18 +245,19 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
187
245
 
188
246
  @staticmethod
189
247
  def get_objects_by_name(
190
- names_to_find: set[str],
191
- objects_to_search: list[SecurityContentObject_Abstract]
248
+ names_to_find: set[str], objects_to_search: list[SecurityContentObject_Abstract]
192
249
  ) -> Tuple[list[SecurityContentObject_Abstract], set[str]]:
193
250
  raise Exception("get_objects_by_name deprecated")
194
- found_objects = list(filter(lambda obj: obj.name in names_to_find, objects_to_search))
251
+ found_objects = list(
252
+ filter(lambda obj: obj.name in names_to_find, objects_to_search)
253
+ )
195
254
  found_names = set([obj.name for obj in found_objects])
196
255
  missing_names = names_to_find - found_names
197
256
  return found_objects, missing_names
198
257
 
199
258
  @staticmethod
200
259
  def create_filename_to_content_dict(
201
- all_objects: list[SecurityContentObject_Abstract]
260
+ all_objects: list[SecurityContentObject_Abstract],
202
261
  ) -> dict[str, SecurityContentObject_Abstract]:
203
262
  name_dict: dict[str, SecurityContentObject_Abstract] = dict()
204
263
  for object in all_objects:
@@ -206,7 +265,9 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
206
265
  # SecurityContentObject (e.g. filter macros that are created at runtime but have no
207
266
  # actual file associated)
208
267
  if object.file_path is None:
209
- raise ValueError(f"SecurityContentObject is missing a file_path: {object.name}")
268
+ raise ValueError(
269
+ f"SecurityContentObject is missing a file_path: {object.name}"
270
+ )
210
271
  name_dict[str(pathlib.Path(object.file_path))] = object
211
272
  return name_dict
212
273
 
@@ -223,12 +284,16 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
223
284
 
224
285
  def __lt__(self, other: object) -> bool:
225
286
  if not isinstance(other, SecurityContentObject_Abstract):
226
- raise Exception(f"SecurityContentObject can only be compared to each other, not to {type(other)}")
287
+ raise Exception(
288
+ f"SecurityContentObject can only be compared to each other, not to {type(other)}"
289
+ )
227
290
  return self.name < other.name
228
291
 
229
292
  def __eq__(self, other: object) -> bool:
230
293
  if not isinstance(other, SecurityContentObject_Abstract):
231
- raise Exception(f"SecurityContentObject can only be compared to each other, not to {type(other)}")
294
+ raise Exception(
295
+ f"SecurityContentObject can only be compared to each other, not to {type(other)}"
296
+ )
232
297
 
233
298
  if id(self) == id(other) and self.name == other.name and self.id == other.id:
234
299
  # Yes, this is the same object
@@ -8,6 +8,7 @@ from contentctl.objects.deployment_rba import DeploymentRBA
8
8
  from contentctl.objects.deployment_slack import DeploymentSlack
9
9
  from contentctl.objects.deployment_phantom import DeploymentPhantom
10
10
 
11
+
11
12
  class AlertAction(BaseModel):
12
13
  model_config = ConfigDict(extra="forbid")
13
14
  email: Optional[DeploymentEmail] = None
@@ -16,26 +17,25 @@ class AlertAction(BaseModel):
16
17
  slack: Optional[DeploymentSlack] = None
17
18
  phantom: Optional[DeploymentPhantom] = None
18
19
 
19
-
20
20
  @model_serializer
21
21
  def serialize_model(self):
22
- #Call serializer for parent
22
+ # Call serializer for parent
23
23
  model = {}
24
24
 
25
25
  if self.email is not None:
26
26
  raise Exception("Email not implemented")
27
27
 
28
28
  if self.notable is not None:
29
- model['notable'] = self.notable
29
+ model["notable"] = self.notable
30
30
 
31
31
  if self.rba is not None and self.rba.enabled:
32
- model['rba'] = {'enabled': "true"}
32
+ model["rba"] = {"enabled": "true"}
33
33
 
34
34
  if self.slack is not None:
35
35
  raise Exception("Slack not implemented")
36
-
36
+
37
37
  if self.phantom is not None:
38
38
  raise Exception("Phantom not implemented")
39
-
40
- #return the model
41
- return model
39
+
40
+ # return the model
41
+ return model
@@ -3,4 +3,4 @@ from typing import Annotated
3
3
 
4
4
  CVE_TYPE = Annotated[str, Field(pattern=r"^CVE-[1|2]\d{3}-\d+$")]
5
5
  MITRE_ATTACK_ID_TYPE = Annotated[str, Field(pattern=r"^T\d{4}(.\d{3})?$")]
6
- APPID_TYPE = Annotated[str,Field(pattern="^[a-zA-Z0-9_-]+$")]
6
+ APPID_TYPE = Annotated[str, Field(pattern="^[a-zA-Z0-9_-]+$")]
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
  from typing import TYPE_CHECKING
3
+
3
4
  if TYPE_CHECKING:
4
5
  from contentctl.objects.config import validate
5
6
 
@@ -11,12 +12,13 @@ import pathlib
11
12
  from enum import StrEnum, auto
12
13
  import uuid
13
14
 
14
- class SupportedPlatform(StrEnum):
15
+
16
+ class SupportedPlatform(StrEnum):
15
17
  windows = auto()
16
18
  linux = auto()
17
19
  macos = auto()
18
20
  containers = auto()
19
- # Because the following fields contain special characters
21
+ # Because the following fields contain special characters
20
22
  # (which cannot be field names) we must specifiy them manually
21
23
  google_workspace = "google-workspace"
22
24
  iaas_gcp = "iaas:gcp"
@@ -24,7 +26,6 @@ class SupportedPlatform(StrEnum):
24
26
  iaas_aws = "iaas:aws"
25
27
  azure_ad = "azure-ad"
26
28
  office_365 = "office-365"
27
-
28
29
 
29
30
 
30
31
  class InputArgumentType(StrEnum):
@@ -40,29 +41,33 @@ class InputArgumentType(StrEnum):
40
41
  Path = "Path"
41
42
  Url = "Url"
42
43
 
44
+
43
45
  class AtomicExecutor(BaseModel):
44
46
  model_config = ConfigDict(extra="forbid")
45
47
  name: str
46
- elevation_required: Optional[bool] = False #Appears to be optional
48
+ elevation_required: Optional[bool] = False # Appears to be optional
47
49
  command: Optional[str] = None
48
50
  steps: Optional[str] = None
49
51
  cleanup_command: Optional[str] = None
50
52
 
51
- @model_validator(mode='after')
52
- def ensure_mutually_exclusive_fields(self)->Self:
53
+ @model_validator(mode="after")
54
+ def ensure_mutually_exclusive_fields(self) -> Self:
53
55
  if self.command is not None and self.steps is not None:
54
- raise ValueError("command and steps cannot both be defined in the executor section. Exactly one must be defined.")
56
+ raise ValueError(
57
+ "command and steps cannot both be defined in the executor section. Exactly one must be defined."
58
+ )
55
59
  elif self.command is None and self.steps is None:
56
- raise ValueError("Neither command nor steps were defined in the executor section. Exactly one must be defined.")
60
+ raise ValueError(
61
+ "Neither command nor steps were defined in the executor section. Exactly one must be defined."
62
+ )
57
63
  return self
58
-
59
64
 
60
65
 
61
66
  class InputArgument(BaseModel):
62
- model_config = ConfigDict(extra='forbid')
67
+ model_config = ConfigDict(extra="forbid")
63
68
  description: str
64
69
  type: InputArgumentType
65
- default: Union[str,int,float,None] = None
70
+ default: Union[str, int, float, None] = None
66
71
 
67
72
 
68
73
  class DependencyExecutorType(StrEnum):
@@ -71,43 +76,51 @@ class DependencyExecutorType(StrEnum):
71
76
  bash = auto()
72
77
  command_prompt = auto()
73
78
 
79
+
74
80
  class AtomicDependency(BaseModel):
75
- model_config = ConfigDict(extra='forbid')
81
+ model_config = ConfigDict(extra="forbid")
76
82
  description: str
77
83
  prereq_command: str
78
84
  get_prereq_command: str
79
85
 
86
+
80
87
  class AtomicTest(BaseModel):
81
- model_config = ConfigDict(extra='forbid')
88
+ model_config = ConfigDict(extra="forbid")
82
89
  name: str
83
90
  auto_generated_guid: UUID4
84
91
  description: str
85
92
  supported_platforms: List[SupportedPlatform]
86
93
  executor: AtomicExecutor
87
- input_arguments: Optional[Dict[str,InputArgument]] = None
94
+ input_arguments: Optional[Dict[str, InputArgument]] = None
88
95
  dependencies: Optional[List[AtomicDependency]] = None
89
96
  dependency_executor_name: Optional[DependencyExecutorType] = None
90
97
 
91
98
  @staticmethod
92
99
  def AtomicTestWhenTestIsMissing(auto_generated_guid: UUID4) -> AtomicTest:
93
- return AtomicTest(name="Missing Atomic",
94
- auto_generated_guid=auto_generated_guid,
95
- description="This is a placeholder AtomicTest. Either the auto_generated_guid is incorrect or it there was an exception while parsing its AtomicFile.",
96
- supported_platforms=[],
97
- executor=AtomicExecutor(name="Placeholder Executor (failed to find auto_generated_guid)",
98
- command="Placeholder command (failed to find auto_generated_guid)"))
99
-
100
+ return AtomicTest(
101
+ name="Missing Atomic",
102
+ auto_generated_guid=auto_generated_guid,
103
+ description="This is a placeholder AtomicTest. Either the auto_generated_guid is incorrect or it there was an exception while parsing its AtomicFile.",
104
+ supported_platforms=[],
105
+ executor=AtomicExecutor(
106
+ name="Placeholder Executor (failed to find auto_generated_guid)",
107
+ command="Placeholder command (failed to find auto_generated_guid)",
108
+ ),
109
+ )
110
+
100
111
  @classmethod
101
- def parseArtRepo(cls, repo_path:pathlib.Path)->dict[uuid.UUID, AtomicTest]:
112
+ def parseArtRepo(cls, repo_path: pathlib.Path) -> dict[uuid.UUID, AtomicTest]:
102
113
  test_mapping: dict[uuid.UUID, AtomicTest] = {}
103
- atomics_path = repo_path/"atomics"
114
+ atomics_path = repo_path / "atomics"
104
115
  if not atomics_path.is_dir():
105
- raise FileNotFoundError(f"WARNING: Atomic Red Team repo exists at {repo_path}, "
106
- f"but atomics directory does NOT exist at {atomics_path}. "
107
- "Was it deleted or renamed?")
108
-
109
- atomic_files:List[AtomicFile] = []
110
- error_messages:List[str] = []
116
+ raise FileNotFoundError(
117
+ f"WARNING: Atomic Red Team repo exists at {repo_path}, "
118
+ f"but atomics directory does NOT exist at {atomics_path}. "
119
+ "Was it deleted or renamed?"
120
+ )
121
+
122
+ atomic_files: List[AtomicFile] = []
123
+ error_messages: List[str] = []
111
124
  for obj_path in atomics_path.glob("**/T*.yaml"):
112
125
  try:
113
126
  atomic_files.append(cls.constructAtomicFile(obj_path))
@@ -115,14 +128,16 @@ class AtomicTest(BaseModel):
115
128
  error_messages.append(f"File [{obj_path}]\n{str(e)}")
116
129
 
117
130
  if len(error_messages) > 0:
118
- exceptions_string = '\n\n'.join(error_messages)
119
- print(f"WARNING: The following [{len(error_messages)}] ERRORS were generated when parsing the Atomic Red Team Repo.\n"
120
- "Please raise an issue so that they can be fixed at https://github.com/redcanaryco/atomic-red-team/issues.\n"
121
- "Note that this is only a warning and contentctl will ignore Atomics contained in these files.\n"
122
- f"However, if you have written a detection that references them, 'contentctl build --enrichments' will fail:\n\n{exceptions_string}")
123
-
131
+ exceptions_string = "\n\n".join(error_messages)
132
+ print(
133
+ f"WARNING: The following [{len(error_messages)}] ERRORS were generated when parsing the Atomic Red Team Repo.\n"
134
+ "Please raise an issue so that they can be fixed at https://github.com/redcanaryco/atomic-red-team/issues.\n"
135
+ "Note that this is only a warning and contentctl will ignore Atomics contained in these files.\n"
136
+ f"However, if you have written a detection that references them, 'contentctl build --enrichments' will fail:\n\n{exceptions_string}"
137
+ )
138
+
124
139
  # Now iterate over all the files, collect all the tests, and return the dict mapping
125
- redefined_guids:set[uuid.UUID] = set()
140
+ redefined_guids: set[uuid.UUID] = set()
126
141
  for atomic_file in atomic_files:
127
142
  for atomic_test in atomic_file.atomic_tests:
128
143
  if atomic_test.auto_generated_guid in test_mapping:
@@ -130,23 +145,25 @@ class AtomicTest(BaseModel):
130
145
  else:
131
146
  test_mapping[atomic_test.auto_generated_guid] = atomic_test
132
147
  if len(redefined_guids) > 0:
133
- guids_string = '\n\t'.join([str(guid) for guid in redefined_guids])
134
- raise Exception(f"The following [{len(redefined_guids)}] Atomic Test"
135
- " auto_generated_guid(s) were defined more than once. "
136
- f"auto_generated_guids MUST be unique:\n\t{guids_string}")
148
+ guids_string = "\n\t".join([str(guid) for guid in redefined_guids])
149
+ raise Exception(
150
+ f"The following [{len(redefined_guids)}] Atomic Test"
151
+ " auto_generated_guid(s) were defined more than once. "
152
+ f"auto_generated_guids MUST be unique:\n\t{guids_string}"
153
+ )
137
154
 
138
155
  print(f"Successfully parsed [{len(test_mapping)}] Atomic Red Team Tests!")
139
156
  return test_mapping
140
-
157
+
141
158
  @classmethod
142
- def constructAtomicFile(cls, file_path:pathlib.Path)->AtomicFile:
143
- yml_dict = YmlReader.load_file(file_path)
159
+ def constructAtomicFile(cls, file_path: pathlib.Path) -> AtomicFile:
160
+ yml_dict = YmlReader.load_file(file_path)
144
161
  atomic_file = AtomicFile.model_validate(yml_dict)
145
162
  return atomic_file
146
163
 
147
164
 
148
165
  class AtomicFile(BaseModel):
149
- model_config = ConfigDict(extra='forbid')
166
+ model_config = ConfigDict(extra="forbid")
150
167
  file_path: FilePath
151
168
  attack_technique: str
152
169
  display_name: str
@@ -154,18 +171,18 @@ class AtomicFile(BaseModel):
154
171
 
155
172
 
156
173
  class AtomicEnrichment(BaseModel):
157
- data: dict[uuid.UUID,AtomicTest] = dataclasses.field(default_factory = dict)
174
+ data: dict[uuid.UUID, AtomicTest] = dataclasses.field(default_factory=dict)
158
175
  use_enrichment: bool = False
159
176
 
160
177
  @classmethod
161
- def getAtomicEnrichment(cls, config:validate)->AtomicEnrichment:
178
+ def getAtomicEnrichment(cls, config: validate) -> AtomicEnrichment:
162
179
  enrichment = AtomicEnrichment(use_enrichment=config.enrichments)
163
180
  if config.enrichments:
164
181
  enrichment.data = AtomicTest.parseArtRepo(config.atomic_red_team_repo_path)
165
182
 
166
183
  return enrichment
167
184
 
168
- def getAtomic(self, atomic_guid: uuid.UUID)->AtomicTest:
185
+ def getAtomic(self, atomic_guid: uuid.UUID) -> AtomicTest:
169
186
  if self.use_enrichment:
170
187
  if atomic_guid in self.data:
171
188
  return self.data[atomic_guid]
@@ -175,10 +192,3 @@ class AtomicEnrichment(BaseModel):
175
192
  # If enrichment is not enabled, for the sake of compatability
176
193
  # return a stub test with no useful or meaningful information.
177
194
  return AtomicTest.AtomicTestWhenTestIsMissing(atomic_guid)
178
-
179
-
180
-
181
-
182
-
183
-
184
-
@@ -2,7 +2,7 @@ from enum import StrEnum
2
2
  from typing import Union
3
3
  from abc import ABC, abstractmethod
4
4
 
5
- from pydantic import BaseModel,ConfigDict
5
+ from pydantic import BaseModel, ConfigDict
6
6
 
7
7
  from contentctl.objects.base_test_result import BaseTestResult
8
8
 
@@ -11,6 +11,7 @@ class TestType(StrEnum):
11
11
  """
12
12
  Types of tests
13
13
  """
14
+
14
15
  UNIT = "unit"
15
16
  INTEGRATION = "integration"
16
17
  MANUAL = "manual"
@@ -2,7 +2,7 @@ from typing import Union, Any
2
2
  from enum import StrEnum
3
3
 
4
4
  from pydantic import ConfigDict, BaseModel
5
- from splunklib.data import Record # type: ignore
5
+ from splunklib.data import Record # type: ignore
6
6
 
7
7
  from contentctl.helper.utils import Utils
8
8
 
@@ -12,6 +12,7 @@ from contentctl.helper.utils import Utils
12
12
  # type; remove mypy ignores associated w/ these typing issues once we do
13
13
  class TestResultStatus(StrEnum):
14
14
  """Enum for test status (e.g. pass/fail)"""
15
+
15
16
  # Test failed (detection did NOT fire appropriately)
16
17
  FAIL = "fail"
17
18
 
@@ -35,6 +36,7 @@ class BaseTestResult(BaseModel):
35
36
  """
36
37
  Base class for test results
37
38
  """
39
+
38
40
  # Message for the result
39
41
  message: Union[None, str] = None
40
42
 
@@ -54,10 +56,7 @@ class BaseTestResult(BaseModel):
54
56
  sid_link: Union[None, str] = None
55
57
 
56
58
  # Needed to allow for embedding of Exceptions in the model
57
- model_config = ConfigDict(
58
- validate_assignment=True,
59
- arbitrary_types_allowed=True
60
- )
59
+ model_config = ConfigDict(validate_assignment=True, arbitrary_types_allowed=True)
61
60
 
62
61
  @property
63
62
  def passed(self) -> bool:
@@ -81,7 +80,10 @@ class BaseTestResult(BaseModel):
81
80
  Property returning True if status is FAIL or ERROR; False otherwise (PASS, SKIP)
82
81
  :returns: bool indicating fialure if True
83
82
  """
84
- return self.status == TestResultStatus.FAIL or self.status == TestResultStatus.ERROR
83
+ return (
84
+ self.status == TestResultStatus.FAIL
85
+ or self.status == TestResultStatus.ERROR
86
+ )
85
87
 
86
88
  @property
87
89
  def complete(self) -> bool:
@@ -94,7 +96,13 @@ class BaseTestResult(BaseModel):
94
96
  def get_summary_dict(
95
97
  self,
96
98
  model_fields: list[str] = [
97
- "success", "exception", "message", "sid_link", "status", "duration", "wait_duration"
99
+ "success",
100
+ "exception",
101
+ "message",
102
+ "sid_link",
103
+ "status",
104
+ "duration",
105
+ "wait_duration",
98
106
  ],
99
107
  job_fields: list[str] = ["search", "resultCount", "runDuration"],
100
108
  ) -> dict[str, Any]:
@@ -125,7 +133,7 @@ class BaseTestResult(BaseModel):
125
133
  # Grab the job content fields required
126
134
  for field in job_fields:
127
135
  if self.job_content is not None:
128
- value: Any = self.job_content.get(field, None) # type: ignore
136
+ value: Any = self.job_content.get(field, None) # type: ignore
129
137
 
130
138
  # convert runDuration to a fixed width string representation of a float
131
139
  if field == "runDuration":