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.
- contentctl/__init__.py +1 -1
- contentctl/actions/build.py +88 -55
- contentctl/actions/deploy_acs.py +29 -24
- contentctl/actions/detection_testing/DetectionTestingManager.py +66 -41
- contentctl/actions/detection_testing/GitService.py +134 -76
- contentctl/actions/detection_testing/generate_detection_coverage_badge.py +48 -30
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +163 -124
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +45 -32
- contentctl/actions/detection_testing/progress_bar.py +3 -0
- contentctl/actions/detection_testing/views/DetectionTestingView.py +15 -18
- contentctl/actions/detection_testing/views/DetectionTestingViewCLI.py +1 -5
- contentctl/actions/detection_testing/views/DetectionTestingViewFile.py +2 -2
- contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py +1 -4
- contentctl/actions/doc_gen.py +9 -5
- contentctl/actions/initialize.py +45 -33
- contentctl/actions/inspect.py +118 -61
- contentctl/actions/new_content.py +78 -50
- contentctl/actions/release_notes.py +276 -146
- contentctl/actions/reporting.py +23 -19
- contentctl/actions/test.py +31 -25
- contentctl/actions/validate.py +54 -34
- contentctl/api.py +54 -45
- contentctl/contentctl.py +12 -13
- contentctl/enrichments/attack_enrichment.py +112 -72
- contentctl/enrichments/cve_enrichment.py +34 -28
- contentctl/enrichments/splunk_app_enrichment.py +38 -36
- contentctl/helper/link_validator.py +101 -78
- contentctl/helper/splunk_app.py +69 -41
- contentctl/helper/utils.py +58 -39
- contentctl/input/director.py +69 -37
- contentctl/input/new_content_questions.py +26 -34
- contentctl/input/yml_reader.py +22 -17
- contentctl/objects/abstract_security_content_objects/detection_abstract.py +250 -314
- contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +58 -36
- contentctl/objects/alert_action.py +8 -8
- contentctl/objects/annotated_types.py +1 -1
- contentctl/objects/atomic.py +64 -54
- contentctl/objects/base_test.py +2 -1
- contentctl/objects/base_test_result.py +16 -8
- contentctl/objects/baseline.py +41 -30
- contentctl/objects/baseline_tags.py +29 -22
- contentctl/objects/config.py +772 -560
- contentctl/objects/constants.py +29 -58
- contentctl/objects/correlation_search.py +75 -55
- contentctl/objects/dashboard.py +55 -41
- contentctl/objects/data_source.py +13 -13
- contentctl/objects/deployment.py +44 -37
- contentctl/objects/deployment_email.py +1 -1
- contentctl/objects/deployment_notable.py +2 -1
- contentctl/objects/deployment_phantom.py +5 -5
- contentctl/objects/deployment_rba.py +1 -1
- contentctl/objects/deployment_scheduling.py +1 -1
- contentctl/objects/deployment_slack.py +1 -1
- contentctl/objects/detection.py +5 -2
- contentctl/objects/detection_metadata.py +1 -0
- contentctl/objects/detection_stanza.py +7 -2
- contentctl/objects/detection_tags.py +54 -64
- contentctl/objects/drilldown.py +66 -35
- contentctl/objects/enums.py +61 -43
- contentctl/objects/errors.py +16 -24
- contentctl/objects/integration_test.py +3 -3
- contentctl/objects/integration_test_result.py +1 -0
- contentctl/objects/investigation.py +41 -26
- contentctl/objects/investigation_tags.py +29 -17
- contentctl/objects/lookup.py +234 -113
- contentctl/objects/macro.py +55 -38
- contentctl/objects/manual_test.py +3 -3
- contentctl/objects/manual_test_result.py +1 -0
- contentctl/objects/mitre_attack_enrichment.py +17 -16
- contentctl/objects/notable_action.py +2 -1
- contentctl/objects/notable_event.py +1 -3
- contentctl/objects/playbook.py +37 -35
- contentctl/objects/playbook_tags.py +22 -16
- contentctl/objects/rba.py +14 -8
- contentctl/objects/risk_analysis_action.py +15 -11
- contentctl/objects/risk_event.py +27 -20
- contentctl/objects/risk_object.py +1 -0
- contentctl/objects/savedsearches_conf.py +9 -7
- contentctl/objects/security_content_object.py +5 -2
- contentctl/objects/story.py +45 -44
- contentctl/objects/story_tags.py +56 -44
- contentctl/objects/test_group.py +5 -2
- contentctl/objects/threat_object.py +1 -0
- contentctl/objects/throttling.py +27 -18
- contentctl/objects/unit_test.py +3 -4
- contentctl/objects/unit_test_baseline.py +4 -5
- contentctl/objects/unit_test_result.py +6 -6
- contentctl/output/api_json_output.py +22 -22
- contentctl/output/attack_nav_output.py +21 -21
- contentctl/output/attack_nav_writer.py +29 -37
- contentctl/output/conf_output.py +230 -174
- contentctl/output/data_source_writer.py +38 -25
- contentctl/output/doc_md_output.py +53 -27
- contentctl/output/jinja_writer.py +19 -15
- contentctl/output/json_writer.py +20 -8
- contentctl/output/svg_output.py +56 -38
- contentctl/output/templates/savedsearches_detections.j2 +1 -1
- contentctl/output/templates/transforms.j2 +2 -2
- contentctl/output/yml_writer.py +18 -24
- {contentctl-5.0.0a0.dist-info → contentctl-5.0.0a3.dist-info}/METADATA +1 -1
- contentctl-5.0.0a3.dist-info/RECORD +168 -0
- contentctl/actions/initialize_old.py +0 -245
- contentctl/objects/observable.py +0 -39
- contentctl-5.0.0a0.dist-info/RECORD +0 -170
- {contentctl-5.0.0a0.dist-info → contentctl-5.0.0a3.dist-info}/LICENSE.md +0 -0
- {contentctl-5.0.0a0.dist-info → contentctl-5.0.0a3.dist-info}/WHEEL +0 -0
- {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(...)
|
|
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
|
-
|
|
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(
|
|
64
|
-
|
|
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
|
|
78
|
-
.replace(
|
|
79
|
-
.replace(
|
|
80
|
-
.replace(
|
|
81
|
-
.replace(
|
|
82
|
-
.lower()
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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[
|
|
29
|
+
model["notable"] = self.notable
|
|
30
30
|
|
|
31
31
|
if self.rba is not None and self.rba.enabled:
|
|
32
|
-
model[
|
|
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_-]+$")]
|
contentctl/objects/atomic.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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=
|
|
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(
|
|
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(
|
|
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=
|
|
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=
|
|
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=
|
|
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(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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 =
|
|
119
|
-
print(
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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 =
|
|
134
|
-
raise Exception(
|
|
135
|
-
|
|
136
|
-
|
|
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=
|
|
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
|
|
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
|
-
|
contentctl/objects/base_test.py
CHANGED
|
@@ -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
|
|
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
|
|
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",
|
|
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)
|
|
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":
|