contentctl 5.0.0a0__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 (107) 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 +134 -76
  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 +12 -13
  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 +772 -560
  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/savedsearches_detections.j2 +1 -1
  98. contentctl/output/templates/transforms.j2 +2 -2
  99. contentctl/output/yml_writer.py +18 -24
  100. {contentctl-5.0.0a0.dist-info → contentctl-5.0.0a3.dist-info}/METADATA +1 -1
  101. contentctl-5.0.0a3.dist-info/RECORD +168 -0
  102. contentctl/actions/initialize_old.py +0 -245
  103. contentctl/objects/observable.py +0 -39
  104. contentctl-5.0.0a0.dist-info/RECORD +0 -170
  105. {contentctl-5.0.0a0.dist-info → contentctl-5.0.0a3.dist-info}/LICENSE.md +0 -0
  106. {contentctl-5.0.0a0.dist-info → contentctl-5.0.0a3.dist-info}/WHEEL +0 -0
  107. {contentctl-5.0.0a0.dist-info → contentctl-5.0.0a3.dist-info}/entry_points.txt +0 -0
@@ -5,7 +5,6 @@ if TYPE_CHECKING:
5
5
  from contentctl.objects.deployment import Deployment
6
6
  from contentctl.objects.security_content_object import SecurityContentObject
7
7
  from contentctl.input.director import DirectorOutputDto
8
- from contentctl.objects.config import CustomApp
9
8
 
10
9
  from contentctl.objects.enums import AnalyticsType
11
10
  from contentctl.objects.constants import CONTENTCTL_MAX_STANZA_LENGTH
@@ -22,7 +21,7 @@ from pydantic import (
22
21
  HttpUrl,
23
22
  NonNegativeInt,
24
23
  ConfigDict,
25
- model_serializer
24
+ model_serializer,
26
25
  )
27
26
  from typing import Tuple, Optional, List, Union
28
27
  import pathlib
@@ -32,13 +31,13 @@ NO_FILE_NAME = "NO_FILE_NAME"
32
31
 
33
32
 
34
33
  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)
34
+ model_config = ConfigDict(validate_default=True, extra="forbid")
35
+ name: str = Field(..., max_length=99)
36
+ author: str = Field(..., max_length=255)
38
37
  date: datetime.date = Field(...)
39
38
  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)
39
+ id: uuid.UUID = Field(...) # we set a default here until all content has a uuid
40
+ description: str = Field(..., max_length=10000)
42
41
  file_path: Optional[FilePath] = None
43
42
  references: Optional[List[HttpUrl]] = None
44
43
 
@@ -54,15 +53,18 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
54
53
  "version": self.version,
55
54
  "id": str(self.id),
56
55
  "description": self.description,
57
- "references": [str(url) for url in self.references or []]
56
+ "references": [str(url) for url in self.references or []],
58
57
  }
59
-
60
-
61
- def check_conf_stanza_max_length(self, stanza_name:str, max_stanza_length:int=CONTENTCTL_MAX_STANZA_LENGTH) -> None:
58
+
59
+ def check_conf_stanza_max_length(
60
+ self, stanza_name: str, max_stanza_length: int = CONTENTCTL_MAX_STANZA_LENGTH
61
+ ) -> None:
62
62
  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
-
63
+ raise ValueError(
64
+ f"conf stanza may only be {max_stanza_length} characters, "
65
+ f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' "
66
+ )
67
+
66
68
  @staticmethod
67
69
  def objectListToNameList(objects: list[SecurityContentObject]) -> list[str]:
68
70
  return [object.getName() for object in objects]
@@ -74,17 +76,19 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
74
76
 
75
77
  @classmethod
76
78
  def contentNameToFileName(cls, content_name: str) -> str:
77
- return content_name \
78
- .replace(' ', '_') \
79
- .replace('-', '_') \
80
- .replace('.', '_') \
81
- .replace('/', '_') \
82
- .lower() + ".yml"
79
+ return (
80
+ content_name.replace(" ", "_")
81
+ .replace("-", "_")
82
+ .replace(".", "_")
83
+ .replace("/", "_")
84
+ .lower()
85
+ + ".yml"
86
+ )
83
87
 
84
88
  def ensureFileNameMatchesSearchName(self):
85
89
  file_name = self.contentNameToFileName(self.name)
86
90
 
87
- if (self.file_path is not None and file_name != self.file_path.name):
91
+ if self.file_path is not None and file_name != self.file_path.name:
88
92
  raise ValueError(
89
93
  f"The file name MUST be based off the content 'name' field:\n"
90
94
  f"\t- Expected File Name: {file_name}\n"
@@ -93,7 +97,7 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
93
97
 
94
98
  return self
95
99
 
96
- @field_validator('file_path')
100
+ @field_validator("file_path")
97
101
  @classmethod
98
102
  def file_path_valid(cls, v: Optional[pathlib.PosixPath], info: ValidationInfo):
99
103
  if not v:
@@ -110,7 +114,9 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
110
114
  return [str(url) for url in self.references or []]
111
115
 
112
116
  @classmethod
113
- def mapNamesToSecurityContentObjects(cls, v: list[str], director: Union[DirectorOutputDto, None]) -> list[Self]:
117
+ def mapNamesToSecurityContentObjects(
118
+ cls, v: list[str], director: Union[DirectorOutputDto, None]
119
+ ) -> list[Self]:
114
120
  if director is not None:
115
121
  name_map = director.name_to_content_map
116
122
  else:
@@ -130,7 +136,9 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
130
136
 
131
137
  errors: list[str] = []
132
138
  if len(missing_objects) > 0:
133
- errors.append(f"Failed to find the following '{cls.__name__}': {missing_objects}")
139
+ errors.append(
140
+ f"Failed to find the following '{cls.__name__}': {missing_objects}"
141
+ )
134
142
  if len(mistyped_objects) > 0:
135
143
  for mistyped_object in mistyped_objects:
136
144
  errors.append(
@@ -142,13 +150,16 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
142
150
  error_string = "\n - ".join(errors)
143
151
  raise ValueError(
144
152
  f"Found {len(errors)} issues when resolving references Security Content Object "
145
- f"names:\n - {error_string}")
153
+ f"names:\n - {error_string}"
154
+ )
146
155
 
147
156
  # Sort all objects sorted by name
148
157
  return sorted(mappedObjects, key=lambda o: o.name)
149
158
 
150
159
  @staticmethod
151
- def getDeploymentFromType(typeField: Union[str, None], info: ValidationInfo) -> Deployment:
160
+ def getDeploymentFromType(
161
+ typeField: Union[str, None], info: ValidationInfo
162
+ ) -> Deployment:
152
163
  if typeField is None:
153
164
  raise ValueError("'type:' field is missing from YML.")
154
165
 
@@ -157,21 +168,25 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
157
168
 
158
169
  director: Optional[DirectorOutputDto] = info.context.get("output_dto", None)
159
170
  if not director:
160
- raise ValueError("Cannot set deployment - DirectorOutputDto not passed to Detection Constructor in context")
171
+ raise ValueError(
172
+ "Cannot set deployment - DirectorOutputDto not passed to Detection Constructor in context"
173
+ )
161
174
 
162
175
  type_to_deployment_name_map = {
163
176
  AnalyticsType.TTP: "ESCU Default Configuration TTP",
164
177
  AnalyticsType.Hunting: "ESCU Default Configuration Hunting",
165
178
  AnalyticsType.Correlation: "ESCU Default Configuration Correlation",
166
179
  AnalyticsType.Anomaly: "ESCU Default Configuration Anomaly",
167
- "Baseline": "ESCU Default Configuration Baseline"
180
+ "Baseline": "ESCU Default Configuration Baseline",
168
181
  }
169
182
  converted_type_field = type_to_deployment_name_map[typeField]
170
183
 
171
184
  # TODO: This is clunky, but is imported here to resolve some circular import errors
172
185
  from contentctl.objects.deployment import Deployment
173
186
 
174
- deployments = Deployment.mapNamesToSecurityContentObjects([converted_type_field], director)
187
+ deployments = Deployment.mapNamesToSecurityContentObjects(
188
+ [converted_type_field], director
189
+ )
175
190
  if len(deployments) == 1:
176
191
  return deployments[0]
177
192
  elif len(deployments) == 0:
@@ -187,18 +202,19 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
187
202
 
188
203
  @staticmethod
189
204
  def get_objects_by_name(
190
- names_to_find: set[str],
191
- objects_to_search: list[SecurityContentObject_Abstract]
205
+ names_to_find: set[str], objects_to_search: list[SecurityContentObject_Abstract]
192
206
  ) -> Tuple[list[SecurityContentObject_Abstract], set[str]]:
193
207
  raise Exception("get_objects_by_name deprecated")
194
- found_objects = list(filter(lambda obj: obj.name in names_to_find, objects_to_search))
208
+ found_objects = list(
209
+ filter(lambda obj: obj.name in names_to_find, objects_to_search)
210
+ )
195
211
  found_names = set([obj.name for obj in found_objects])
196
212
  missing_names = names_to_find - found_names
197
213
  return found_objects, missing_names
198
214
 
199
215
  @staticmethod
200
216
  def create_filename_to_content_dict(
201
- all_objects: list[SecurityContentObject_Abstract]
217
+ all_objects: list[SecurityContentObject_Abstract],
202
218
  ) -> dict[str, SecurityContentObject_Abstract]:
203
219
  name_dict: dict[str, SecurityContentObject_Abstract] = dict()
204
220
  for object in all_objects:
@@ -206,7 +222,9 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
206
222
  # SecurityContentObject (e.g. filter macros that are created at runtime but have no
207
223
  # actual file associated)
208
224
  if object.file_path is None:
209
- raise ValueError(f"SecurityContentObject is missing a file_path: {object.name}")
225
+ raise ValueError(
226
+ f"SecurityContentObject is missing a file_path: {object.name}"
227
+ )
210
228
  name_dict[str(pathlib.Path(object.file_path))] = object
211
229
  return name_dict
212
230
 
@@ -223,12 +241,16 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
223
241
 
224
242
  def __lt__(self, other: object) -> bool:
225
243
  if not isinstance(other, SecurityContentObject_Abstract):
226
- raise Exception(f"SecurityContentObject can only be compared to each other, not to {type(other)}")
244
+ raise Exception(
245
+ f"SecurityContentObject can only be compared to each other, not to {type(other)}"
246
+ )
227
247
  return self.name < other.name
228
248
 
229
249
  def __eq__(self, other: object) -> bool:
230
250
  if not isinstance(other, SecurityContentObject_Abstract):
231
- raise Exception(f"SecurityContentObject can only be compared to each other, not to {type(other)}")
251
+ raise Exception(
252
+ f"SecurityContentObject can only be compared to each other, not to {type(other)}"
253
+ )
232
254
 
233
255
  if id(self) == id(other) and self.name == other.name and self.id == other.id:
234
256
  # 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":