contentctl 4.4.7__py3-none-any.whl → 5.0.0__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 (123) hide show
  1. contentctl/__init__.py +1 -1
  2. contentctl/actions/build.py +102 -57
  3. contentctl/actions/deploy_acs.py +29 -24
  4. contentctl/actions/detection_testing/DetectionTestingManager.py +66 -42
  5. contentctl/actions/detection_testing/GitService.py +134 -76
  6. contentctl/actions/detection_testing/generate_detection_coverage_badge.py +48 -30
  7. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +192 -147
  8. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +45 -32
  9. contentctl/actions/detection_testing/progress_bar.py +9 -6
  10. contentctl/actions/detection_testing/views/DetectionTestingView.py +16 -19
  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 +155 -108
  18. contentctl/actions/release_notes.py +276 -146
  19. contentctl/actions/reporting.py +23 -19
  20. contentctl/actions/test.py +33 -28
  21. contentctl/actions/validate.py +55 -34
  22. contentctl/api.py +54 -45
  23. contentctl/contentctl.py +124 -90
  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 -53
  30. contentctl/input/director.py +68 -36
  31. contentctl/input/new_content_questions.py +27 -35
  32. contentctl/input/yml_reader.py +28 -18
  33. contentctl/objects/abstract_security_content_objects/detection_abstract.py +303 -259
  34. contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +115 -52
  35. contentctl/objects/alert_action.py +10 -9
  36. contentctl/objects/annotated_types.py +1 -1
  37. contentctl/objects/atomic.py +65 -54
  38. contentctl/objects/base_test.py +5 -3
  39. contentctl/objects/base_test_result.py +19 -11
  40. contentctl/objects/baseline.py +62 -30
  41. contentctl/objects/baseline_tags.py +30 -24
  42. contentctl/objects/config.py +790 -597
  43. contentctl/objects/constants.py +33 -56
  44. contentctl/objects/correlation_search.py +150 -136
  45. contentctl/objects/dashboard.py +55 -41
  46. contentctl/objects/data_source.py +16 -17
  47. contentctl/objects/deployment.py +43 -44
  48. contentctl/objects/deployment_email.py +3 -2
  49. contentctl/objects/deployment_notable.py +4 -2
  50. contentctl/objects/deployment_phantom.py +7 -6
  51. contentctl/objects/deployment_rba.py +3 -2
  52. contentctl/objects/deployment_scheduling.py +3 -2
  53. contentctl/objects/deployment_slack.py +3 -2
  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 +58 -103
  58. contentctl/objects/drilldown.py +66 -34
  59. contentctl/objects/enums.py +81 -100
  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 +59 -36
  64. contentctl/objects/investigation_tags.py +30 -19
  65. contentctl/objects/lookup.py +304 -101
  66. contentctl/objects/macro.py +55 -39
  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 +23 -13
  74. contentctl/objects/rba.py +96 -0
  75. contentctl/objects/risk_analysis_action.py +15 -11
  76. contentctl/objects/risk_event.py +110 -160
  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 -45
  82. contentctl/objects/test_attack_data.py +2 -1
  83. contentctl/objects/test_group.py +5 -2
  84. contentctl/objects/threat_object.py +1 -0
  85. contentctl/objects/throttling.py +27 -18
  86. contentctl/objects/unit_test.py +3 -4
  87. contentctl/objects/unit_test_baseline.py +5 -5
  88. contentctl/objects/unit_test_result.py +6 -6
  89. contentctl/output/api_json_output.py +233 -220
  90. contentctl/output/attack_nav_output.py +21 -21
  91. contentctl/output/attack_nav_writer.py +29 -37
  92. contentctl/output/conf_output.py +235 -172
  93. contentctl/output/conf_writer.py +201 -125
  94. contentctl/output/data_source_writer.py +38 -26
  95. contentctl/output/doc_md_output.py +53 -27
  96. contentctl/output/jinja_writer.py +19 -15
  97. contentctl/output/json_writer.py +21 -11
  98. contentctl/output/svg_output.py +56 -38
  99. contentctl/output/templates/analyticstories_detections.j2 +2 -2
  100. contentctl/output/templates/analyticstories_stories.j2 +1 -1
  101. contentctl/output/templates/collections.j2 +1 -1
  102. contentctl/output/templates/doc_detections.j2 +0 -5
  103. contentctl/output/templates/es_investigations_investigations.j2 +1 -1
  104. contentctl/output/templates/es_investigations_stories.j2 +1 -1
  105. contentctl/output/templates/savedsearches_baselines.j2 +2 -2
  106. contentctl/output/templates/savedsearches_detections.j2 +10 -11
  107. contentctl/output/templates/savedsearches_investigations.j2 +2 -2
  108. contentctl/output/templates/transforms.j2 +6 -8
  109. contentctl/output/yml_writer.py +29 -20
  110. contentctl/templates/detections/endpoint/anomalous_usage_of_7zip.yml +16 -34
  111. contentctl/templates/stories/cobalt_strike.yml +1 -0
  112. {contentctl-4.4.7.dist-info → contentctl-5.0.0.dist-info}/METADATA +5 -4
  113. contentctl-5.0.0.dist-info/RECORD +168 -0
  114. {contentctl-4.4.7.dist-info → contentctl-5.0.0.dist-info}/WHEEL +1 -1
  115. contentctl/actions/initialize_old.py +0 -245
  116. contentctl/objects/event_source.py +0 -11
  117. contentctl/objects/observable.py +0 -37
  118. contentctl/output/detection_writer.py +0 -28
  119. contentctl/output/new_content_yml_output.py +0 -56
  120. contentctl/output/yml_output.py +0 -66
  121. contentctl-4.4.7.dist-info/RECORD +0 -173
  122. {contentctl-4.4.7.dist-info → contentctl-5.0.0.dist-info}/LICENSE.md +0 -0
  123. {contentctl-4.4.7.dist-info → contentctl-5.0.0.dist-info}/entry_points.txt +0 -0
@@ -1,52 +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
- # TODO (#266): disable the use_enum_values configuration
35
41
  class SecurityContentObject_Abstract(BaseModel, abc.ABC):
36
- model_config = ConfigDict(use_enum_values=True,validate_default=True)
37
-
38
- name: str = Field(...,max_length=99)
39
- 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)
40
45
  date: datetime.date = Field(...)
41
46
  version: NonNegativeInt = Field(...)
42
- id: uuid.UUID = Field(...) #we set a default here until all content has a uuid
43
- 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)
44
49
  file_path: Optional[FilePath] = None
45
50
  references: Optional[List[HttpUrl]] = None
46
51
 
47
52
  def model_post_init(self, __context: Any) -> None:
48
53
  self.ensureFileNameMatchesSearchName()
49
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
+
50
90
  @model_serializer
51
91
  def serialize_model(self):
52
92
  return {
@@ -56,15 +96,18 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
56
96
  "version": self.version,
57
97
  "id": str(self.id),
58
98
  "description": self.description,
59
- "references": [str(url) for url in self.references or []]
99
+ "references": [str(url) for url in self.references or []],
60
100
  }
61
-
62
-
63
- 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:
64
105
  if len(stanza_name) > max_stanza_length:
65
- raise ValueError(f"conf stanza may only be {max_stanza_length} characters, "
66
- f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' ")
67
-
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
+
68
111
  @staticmethod
69
112
  def objectListToNameList(objects: list[SecurityContentObject]) -> list[str]:
70
113
  return [object.getName() for object in objects]
@@ -76,17 +119,19 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
76
119
 
77
120
  @classmethod
78
121
  def contentNameToFileName(cls, content_name: str) -> str:
79
- return content_name \
80
- .replace(' ', '_') \
81
- .replace('-', '_') \
82
- .replace('.', '_') \
83
- .replace('/', '_') \
84
- .lower() + ".yml"
122
+ return (
123
+ content_name.replace(" ", "_")
124
+ .replace("-", "_")
125
+ .replace(".", "_")
126
+ .replace("/", "_")
127
+ .lower()
128
+ + ".yml"
129
+ )
85
130
 
86
131
  def ensureFileNameMatchesSearchName(self):
87
132
  file_name = self.contentNameToFileName(self.name)
88
133
 
89
- 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:
90
135
  raise ValueError(
91
136
  f"The file name MUST be based off the content 'name' field:\n"
92
137
  f"\t- Expected File Name: {file_name}\n"
@@ -95,7 +140,7 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
95
140
 
96
141
  return self
97
142
 
98
- @field_validator('file_path')
143
+ @field_validator("file_path")
99
144
  @classmethod
100
145
  def file_path_valid(cls, v: Optional[pathlib.PosixPath], info: ValidationInfo):
101
146
  if not v:
@@ -112,7 +157,9 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
112
157
  return [str(url) for url in self.references or []]
113
158
 
114
159
  @classmethod
115
- 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]:
116
163
  if director is not None:
117
164
  name_map = director.name_to_content_map
118
165
  else:
@@ -132,7 +179,9 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
132
179
 
133
180
  errors: list[str] = []
134
181
  if len(missing_objects) > 0:
135
- 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
+ )
136
185
  if len(mistyped_objects) > 0:
137
186
  for mistyped_object in mistyped_objects:
138
187
  errors.append(
@@ -144,13 +193,16 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
144
193
  error_string = "\n - ".join(errors)
145
194
  raise ValueError(
146
195
  f"Found {len(errors)} issues when resolving references Security Content Object "
147
- f"names:\n - {error_string}")
196
+ f"names:\n - {error_string}"
197
+ )
148
198
 
149
199
  # Sort all objects sorted by name
150
200
  return sorted(mappedObjects, key=lambda o: o.name)
151
201
 
152
202
  @staticmethod
153
- def getDeploymentFromType(typeField: Union[str, None], info: ValidationInfo) -> Deployment:
203
+ def getDeploymentFromType(
204
+ typeField: Union[str, None], info: ValidationInfo
205
+ ) -> Deployment:
154
206
  if typeField is None:
155
207
  raise ValueError("'type:' field is missing from YML.")
156
208
 
@@ -159,21 +211,25 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
159
211
 
160
212
  director: Optional[DirectorOutputDto] = info.context.get("output_dto", None)
161
213
  if not director:
162
- 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
+ )
163
217
 
164
218
  type_to_deployment_name_map = {
165
- AnalyticsType.TTP.value: "ESCU Default Configuration TTP",
166
- AnalyticsType.Hunting.value: "ESCU Default Configuration Hunting",
167
- AnalyticsType.Correlation.value: "ESCU Default Configuration Correlation",
168
- AnalyticsType.Anomaly.value: "ESCU Default Configuration Anomaly",
169
- "Baseline": "ESCU Default Configuration Baseline"
219
+ AnalyticsType.TTP: "ESCU Default Configuration TTP",
220
+ AnalyticsType.Hunting: "ESCU Default Configuration Hunting",
221
+ AnalyticsType.Correlation: "ESCU Default Configuration Correlation",
222
+ AnalyticsType.Anomaly: "ESCU Default Configuration Anomaly",
223
+ "Baseline": "ESCU Default Configuration Baseline",
170
224
  }
171
225
  converted_type_field = type_to_deployment_name_map[typeField]
172
226
 
173
227
  # TODO: This is clunky, but is imported here to resolve some circular import errors
174
228
  from contentctl.objects.deployment import Deployment
175
229
 
176
- deployments = Deployment.mapNamesToSecurityContentObjects([converted_type_field], director)
230
+ deployments = Deployment.mapNamesToSecurityContentObjects(
231
+ [converted_type_field], director
232
+ )
177
233
  if len(deployments) == 1:
178
234
  return deployments[0]
179
235
  elif len(deployments) == 0:
@@ -189,18 +245,19 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
189
245
 
190
246
  @staticmethod
191
247
  def get_objects_by_name(
192
- names_to_find: set[str],
193
- objects_to_search: list[SecurityContentObject_Abstract]
248
+ names_to_find: set[str], objects_to_search: list[SecurityContentObject_Abstract]
194
249
  ) -> Tuple[list[SecurityContentObject_Abstract], set[str]]:
195
250
  raise Exception("get_objects_by_name deprecated")
196
- 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
+ )
197
254
  found_names = set([obj.name for obj in found_objects])
198
255
  missing_names = names_to_find - found_names
199
256
  return found_objects, missing_names
200
257
 
201
258
  @staticmethod
202
259
  def create_filename_to_content_dict(
203
- all_objects: list[SecurityContentObject_Abstract]
260
+ all_objects: list[SecurityContentObject_Abstract],
204
261
  ) -> dict[str, SecurityContentObject_Abstract]:
205
262
  name_dict: dict[str, SecurityContentObject_Abstract] = dict()
206
263
  for object in all_objects:
@@ -208,7 +265,9 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
208
265
  # SecurityContentObject (e.g. filter macros that are created at runtime but have no
209
266
  # actual file associated)
210
267
  if object.file_path is None:
211
- 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
+ )
212
271
  name_dict[str(pathlib.Path(object.file_path))] = object
213
272
  return name_dict
214
273
 
@@ -225,12 +284,16 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
225
284
 
226
285
  def __lt__(self, other: object) -> bool:
227
286
  if not isinstance(other, SecurityContentObject_Abstract):
228
- 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
+ )
229
290
  return self.name < other.name
230
291
 
231
292
  def __eq__(self, other: object) -> bool:
232
293
  if not isinstance(other, SecurityContentObject_Abstract):
233
- 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
+ )
234
297
 
235
298
  if id(self) == id(other) and self.name == other.name and self.id == other.id:
236
299
  # Yes, this is the same object
@@ -1,5 +1,5 @@
1
1
  from __future__ import annotations
2
- from pydantic import BaseModel, model_serializer
2
+ from pydantic import BaseModel, model_serializer, ConfigDict
3
3
  from typing import Optional
4
4
 
5
5
  from contentctl.objects.deployment_email import DeploymentEmail
@@ -8,33 +8,34 @@ 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):
13
+ model_config = ConfigDict(extra="forbid")
12
14
  email: Optional[DeploymentEmail] = None
13
15
  notable: Optional[DeploymentNotable] = None
14
16
  rba: Optional[DeploymentRBA] = DeploymentRBA()
15
17
  slack: Optional[DeploymentSlack] = None
16
18
  phantom: Optional[DeploymentPhantom] = None
17
19
 
18
-
19
20
  @model_serializer
20
21
  def serialize_model(self):
21
- #Call serializer for parent
22
+ # Call serializer for parent
22
23
  model = {}
23
24
 
24
25
  if self.email is not None:
25
26
  raise Exception("Email not implemented")
26
27
 
27
28
  if self.notable is not None:
28
- model['notable'] = self.notable
29
+ model["notable"] = self.notable
29
30
 
30
31
  if self.rba is not None and self.rba.enabled:
31
- model['rba'] = {'enabled': "true"}
32
+ model["rba"] = {"enabled": "true"}
32
33
 
33
34
  if self.slack is not None:
34
35
  raise Exception("Slack not implemented")
35
-
36
+
36
37
  if self.phantom is not None:
37
38
  raise Exception("Phantom not implemented")
38
-
39
- #return the model
40
- 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,28 +41,33 @@ class InputArgumentType(StrEnum):
40
41
  Path = "Path"
41
42
  Url = "Url"
42
43
 
44
+
43
45
  class AtomicExecutor(BaseModel):
46
+ model_config = ConfigDict(extra="forbid")
44
47
  name: str
45
- elevation_required: Optional[bool] = False #Appears to be optional
48
+ elevation_required: Optional[bool] = False # Appears to be optional
46
49
  command: Optional[str] = None
47
50
  steps: Optional[str] = None
48
51
  cleanup_command: Optional[str] = None
49
52
 
50
- @model_validator(mode='after')
51
- def ensure_mutually_exclusive_fields(self)->Self:
53
+ @model_validator(mode="after")
54
+ def ensure_mutually_exclusive_fields(self) -> Self:
52
55
  if self.command is not None and self.steps is not None:
53
- 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
+ )
54
59
  elif self.command is None and self.steps is None:
55
- 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
+ )
56
63
  return self
57
-
58
64
 
59
65
 
60
66
  class InputArgument(BaseModel):
61
- model_config = ConfigDict(extra='forbid')
67
+ model_config = ConfigDict(extra="forbid")
62
68
  description: str
63
69
  type: InputArgumentType
64
- default: Union[str,int,float,None] = None
70
+ default: Union[str, int, float, None] = None
65
71
 
66
72
 
67
73
  class DependencyExecutorType(StrEnum):
@@ -70,43 +76,51 @@ class DependencyExecutorType(StrEnum):
70
76
  bash = auto()
71
77
  command_prompt = auto()
72
78
 
79
+
73
80
  class AtomicDependency(BaseModel):
74
- model_config = ConfigDict(extra='forbid')
81
+ model_config = ConfigDict(extra="forbid")
75
82
  description: str
76
83
  prereq_command: str
77
84
  get_prereq_command: str
78
85
 
86
+
79
87
  class AtomicTest(BaseModel):
80
- model_config = ConfigDict(extra='forbid')
88
+ model_config = ConfigDict(extra="forbid")
81
89
  name: str
82
90
  auto_generated_guid: UUID4
83
91
  description: str
84
92
  supported_platforms: List[SupportedPlatform]
85
93
  executor: AtomicExecutor
86
- input_arguments: Optional[Dict[str,InputArgument]] = None
94
+ input_arguments: Optional[Dict[str, InputArgument]] = None
87
95
  dependencies: Optional[List[AtomicDependency]] = None
88
96
  dependency_executor_name: Optional[DependencyExecutorType] = None
89
97
 
90
98
  @staticmethod
91
99
  def AtomicTestWhenTestIsMissing(auto_generated_guid: UUID4) -> AtomicTest:
92
- return AtomicTest(name="Missing Atomic",
93
- auto_generated_guid=auto_generated_guid,
94
- description="This is a placeholder AtomicTest. Either the auto_generated_guid is incorrect or it there was an exception while parsing its AtomicFile.",
95
- supported_platforms=[],
96
- executor=AtomicExecutor(name="Placeholder Executor (failed to find auto_generated_guid)",
97
- command="Placeholder command (failed to find auto_generated_guid)"))
98
-
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
+
99
111
  @classmethod
100
- def parseArtRepo(cls, repo_path:pathlib.Path)->dict[uuid.UUID, AtomicTest]:
112
+ def parseArtRepo(cls, repo_path: pathlib.Path) -> dict[uuid.UUID, AtomicTest]:
101
113
  test_mapping: dict[uuid.UUID, AtomicTest] = {}
102
- atomics_path = repo_path/"atomics"
114
+ atomics_path = repo_path / "atomics"
103
115
  if not atomics_path.is_dir():
104
- raise FileNotFoundError(f"WARNING: Atomic Red Team repo exists at {repo_path}, "
105
- f"but atomics directory does NOT exist at {atomics_path}. "
106
- "Was it deleted or renamed?")
107
-
108
- atomic_files:List[AtomicFile] = []
109
- 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] = []
110
124
  for obj_path in atomics_path.glob("**/T*.yaml"):
111
125
  try:
112
126
  atomic_files.append(cls.constructAtomicFile(obj_path))
@@ -114,14 +128,16 @@ class AtomicTest(BaseModel):
114
128
  error_messages.append(f"File [{obj_path}]\n{str(e)}")
115
129
 
116
130
  if len(error_messages) > 0:
117
- exceptions_string = '\n\n'.join(error_messages)
118
- print(f"WARNING: The following [{len(error_messages)}] ERRORS were generated when parsing the Atomic Red Team Repo.\n"
119
- "Please raise an issue so that they can be fixed at https://github.com/redcanaryco/atomic-red-team/issues.\n"
120
- "Note that this is only a warning and contentctl will ignore Atomics contained in these files.\n"
121
- f"However, if you have written a detection that references them, 'contentctl build --enrichments' will fail:\n\n{exceptions_string}")
122
-
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
+
123
139
  # Now iterate over all the files, collect all the tests, and return the dict mapping
124
- redefined_guids:set[uuid.UUID] = set()
140
+ redefined_guids: set[uuid.UUID] = set()
125
141
  for atomic_file in atomic_files:
126
142
  for atomic_test in atomic_file.atomic_tests:
127
143
  if atomic_test.auto_generated_guid in test_mapping:
@@ -129,23 +145,25 @@ class AtomicTest(BaseModel):
129
145
  else:
130
146
  test_mapping[atomic_test.auto_generated_guid] = atomic_test
131
147
  if len(redefined_guids) > 0:
132
- guids_string = '\n\t'.join([str(guid) for guid in redefined_guids])
133
- raise Exception(f"The following [{len(redefined_guids)}] Atomic Test"
134
- " auto_generated_guid(s) were defined more than once. "
135
- 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
+ )
136
154
 
137
155
  print(f"Successfully parsed [{len(test_mapping)}] Atomic Red Team Tests!")
138
156
  return test_mapping
139
-
157
+
140
158
  @classmethod
141
- def constructAtomicFile(cls, file_path:pathlib.Path)->AtomicFile:
142
- yml_dict = YmlReader.load_file(file_path)
159
+ def constructAtomicFile(cls, file_path: pathlib.Path) -> AtomicFile:
160
+ yml_dict = YmlReader.load_file(file_path)
143
161
  atomic_file = AtomicFile.model_validate(yml_dict)
144
162
  return atomic_file
145
163
 
146
164
 
147
165
  class AtomicFile(BaseModel):
148
- model_config = ConfigDict(extra='forbid')
166
+ model_config = ConfigDict(extra="forbid")
149
167
  file_path: FilePath
150
168
  attack_technique: str
151
169
  display_name: str
@@ -153,18 +171,18 @@ class AtomicFile(BaseModel):
153
171
 
154
172
 
155
173
  class AtomicEnrichment(BaseModel):
156
- data: dict[uuid.UUID,AtomicTest] = dataclasses.field(default_factory = dict)
174
+ data: dict[uuid.UUID, AtomicTest] = dataclasses.field(default_factory=dict)
157
175
  use_enrichment: bool = False
158
176
 
159
177
  @classmethod
160
- def getAtomicEnrichment(cls, config:validate)->AtomicEnrichment:
178
+ def getAtomicEnrichment(cls, config: validate) -> AtomicEnrichment:
161
179
  enrichment = AtomicEnrichment(use_enrichment=config.enrichments)
162
180
  if config.enrichments:
163
181
  enrichment.data = AtomicTest.parseArtRepo(config.atomic_red_team_repo_path)
164
182
 
165
183
  return enrichment
166
184
 
167
- def getAtomic(self, atomic_guid: uuid.UUID)->AtomicTest:
185
+ def getAtomic(self, atomic_guid: uuid.UUID) -> AtomicTest:
168
186
  if self.use_enrichment:
169
187
  if atomic_guid in self.data:
170
188
  return self.data[atomic_guid]
@@ -174,10 +192,3 @@ class AtomicEnrichment(BaseModel):
174
192
  # If enrichment is not enabled, for the sake of compatability
175
193
  # return a stub test with no useful or meaningful information.
176
194
  return AtomicTest.AtomicTestWhenTestIsMissing(atomic_guid)
177
-
178
-
179
-
180
-
181
-
182
-
183
-
@@ -1,16 +1,17 @@
1
- from enum import Enum
1
+ from enum import StrEnum
2
2
  from typing import Union
3
3
  from abc import ABC, abstractmethod
4
4
 
5
- from pydantic import BaseModel
5
+ from pydantic import BaseModel, ConfigDict
6
6
 
7
7
  from contentctl.objects.base_test_result import BaseTestResult
8
8
 
9
9
 
10
- class TestType(str, Enum):
10
+ 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"
@@ -21,6 +22,7 @@ class TestType(str, Enum):
21
22
 
22
23
  # TODO (#224): enforce distinct test names w/in detections
23
24
  class BaseTest(BaseModel, ABC):
25
+ model_config = ConfigDict(extra="forbid")
24
26
  """
25
27
  A test case for a detection
26
28
  """