contentctl 5.0.0a2__py3-none-any.whl → 5.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +2 -4
- 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 +83 -53
- 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 +10 -10
- 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 +255 -323
- contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +111 -46
- 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 +47 -35
- contentctl/objects/baseline_tags.py +29 -22
- contentctl/objects/config.py +1 -1
- contentctl/objects/constants.py +32 -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 +53 -31
- 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 +68 -11
- 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 +54 -49
- 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/analyticstories_detections.j2 +1 -1
- contentctl/output/templates/analyticstories_stories.j2 +1 -1
- contentctl/output/templates/es_investigations_investigations.j2 +1 -1
- contentctl/output/templates/es_investigations_stories.j2 +1 -1
- contentctl/output/templates/savedsearches_baselines.j2 +2 -2
- contentctl/output/templates/savedsearches_detections.j2 +2 -8
- contentctl/output/templates/savedsearches_investigations.j2 +2 -2
- contentctl/output/templates/transforms.j2 +2 -4
- contentctl/output/yml_writer.py +18 -24
- contentctl/templates/stories/cobalt_strike.yml +1 -0
- {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/METADATA +1 -1
- contentctl-5.0.1.dist-info/RECORD +168 -0
- contentctl/actions/initialize_old.py +0 -245
- contentctl/objects/observable.py +0 -39
- contentctl-5.0.0a2.dist-info/RECORD +0 -170
- {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/LICENSE.md +0 -0
- {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/WHEEL +0 -0
- {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/entry_points.txt +0 -0
|
@@ -1,50 +1,92 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
|
|
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
|
-
|
|
20
|
+
ConfigDict,
|
|
19
21
|
Field,
|
|
20
|
-
ValidationInfo,
|
|
21
22
|
FilePath,
|
|
22
23
|
HttpUrl,
|
|
23
24
|
NonNegativeInt,
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
ValidationInfo,
|
|
26
|
+
computed_field,
|
|
27
|
+
field_validator,
|
|
28
|
+
model_serializer,
|
|
26
29
|
)
|
|
27
|
-
from typing import Tuple, Optional, List, Union
|
|
28
|
-
import pathlib
|
|
29
30
|
|
|
31
|
+
from contentctl.objects.constants import (
|
|
32
|
+
CONTENTCTL_MAX_STANZA_LENGTH,
|
|
33
|
+
DEPRECATED_TEMPLATE,
|
|
34
|
+
EXPERIMENTAL_TEMPLATE,
|
|
35
|
+
)
|
|
36
|
+
from contentctl.objects.enums import AnalyticsType, DetectionStatus
|
|
30
37
|
|
|
31
38
|
NO_FILE_NAME = "NO_FILE_NAME"
|
|
32
39
|
|
|
33
40
|
|
|
34
41
|
class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
35
|
-
model_config = ConfigDict(validate_default=True,extra="forbid")
|
|
36
|
-
name: str = Field(...,max_length=99)
|
|
37
|
-
author: str = Field(...,max_length=255)
|
|
42
|
+
model_config = ConfigDict(validate_default=True, extra="forbid")
|
|
43
|
+
name: str = Field(..., max_length=99)
|
|
44
|
+
author: str = Field(..., max_length=255)
|
|
38
45
|
date: datetime.date = Field(...)
|
|
39
46
|
version: NonNegativeInt = Field(...)
|
|
40
|
-
id: uuid.UUID = Field(...)
|
|
41
|
-
description: str = Field(...,max_length=10000)
|
|
47
|
+
id: uuid.UUID = Field(...) # we set a default here until all content has a uuid
|
|
48
|
+
description: str = Field(..., max_length=10000)
|
|
42
49
|
file_path: Optional[FilePath] = None
|
|
43
50
|
references: Optional[List[HttpUrl]] = None
|
|
44
51
|
|
|
45
52
|
def model_post_init(self, __context: Any) -> None:
|
|
46
53
|
self.ensureFileNameMatchesSearchName()
|
|
47
54
|
|
|
55
|
+
@computed_field
|
|
56
|
+
@cached_property
|
|
57
|
+
def status_aware_description(self) -> str:
|
|
58
|
+
"""We need to be able to write out a description that includes information
|
|
59
|
+
about whether or not a detection has been deprecated or not. This is important
|
|
60
|
+
for providing information to the user as well as powering the deprecation
|
|
61
|
+
assistant dashboad(s). Make sure this information is output correctly, if
|
|
62
|
+
appropriate.
|
|
63
|
+
Otherwise, if a detection is not deprecated or experimental, just return th
|
|
64
|
+
unmodified description.
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
NotImplementedError: This content type does not support status_aware_description.
|
|
68
|
+
This is because the object does not define a status field
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
str: description, which may or may not be prefixed with the deprecation/experimental message
|
|
72
|
+
"""
|
|
73
|
+
status = getattr(self, "status", None)
|
|
74
|
+
|
|
75
|
+
if not isinstance(status, DetectionStatus):
|
|
76
|
+
raise NotImplementedError(
|
|
77
|
+
f"Detection status is not implemented for [{self.name}] of type '{type(self).__name__}'"
|
|
78
|
+
)
|
|
79
|
+
if status == DetectionStatus.experimental:
|
|
80
|
+
return EXPERIMENTAL_TEMPLATE.format(
|
|
81
|
+
content_type=type(self).__name__, description=self.description
|
|
82
|
+
)
|
|
83
|
+
elif status == DetectionStatus.deprecated:
|
|
84
|
+
return DEPRECATED_TEMPLATE.format(
|
|
85
|
+
content_type=type(self).__name__, description=self.description
|
|
86
|
+
)
|
|
87
|
+
else:
|
|
88
|
+
return self.description
|
|
89
|
+
|
|
48
90
|
@model_serializer
|
|
49
91
|
def serialize_model(self):
|
|
50
92
|
return {
|
|
@@ -54,15 +96,18 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
|
54
96
|
"version": self.version,
|
|
55
97
|
"id": str(self.id),
|
|
56
98
|
"description": self.description,
|
|
57
|
-
"references": [str(url) for url in self.references or []]
|
|
99
|
+
"references": [str(url) for url in self.references or []],
|
|
58
100
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
101
|
+
|
|
102
|
+
def check_conf_stanza_max_length(
|
|
103
|
+
self, stanza_name: str, max_stanza_length: int = CONTENTCTL_MAX_STANZA_LENGTH
|
|
104
|
+
) -> None:
|
|
62
105
|
if len(stanza_name) > max_stanza_length:
|
|
63
|
-
raise ValueError(
|
|
64
|
-
|
|
65
|
-
|
|
106
|
+
raise ValueError(
|
|
107
|
+
f"conf stanza may only be {max_stanza_length} characters, "
|
|
108
|
+
f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' "
|
|
109
|
+
)
|
|
110
|
+
|
|
66
111
|
@staticmethod
|
|
67
112
|
def objectListToNameList(objects: list[SecurityContentObject]) -> list[str]:
|
|
68
113
|
return [object.getName() for object in objects]
|
|
@@ -74,17 +119,19 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
|
74
119
|
|
|
75
120
|
@classmethod
|
|
76
121
|
def contentNameToFileName(cls, content_name: str) -> str:
|
|
77
|
-
return
|
|
78
|
-
.replace(
|
|
79
|
-
.replace(
|
|
80
|
-
.replace(
|
|
81
|
-
.replace(
|
|
82
|
-
.lower()
|
|
122
|
+
return (
|
|
123
|
+
content_name.replace(" ", "_")
|
|
124
|
+
.replace("-", "_")
|
|
125
|
+
.replace(".", "_")
|
|
126
|
+
.replace("/", "_")
|
|
127
|
+
.lower()
|
|
128
|
+
+ ".yml"
|
|
129
|
+
)
|
|
83
130
|
|
|
84
131
|
def ensureFileNameMatchesSearchName(self):
|
|
85
132
|
file_name = self.contentNameToFileName(self.name)
|
|
86
133
|
|
|
87
|
-
if
|
|
134
|
+
if self.file_path is not None and file_name != self.file_path.name:
|
|
88
135
|
raise ValueError(
|
|
89
136
|
f"The file name MUST be based off the content 'name' field:\n"
|
|
90
137
|
f"\t- Expected File Name: {file_name}\n"
|
|
@@ -93,7 +140,7 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
|
93
140
|
|
|
94
141
|
return self
|
|
95
142
|
|
|
96
|
-
@field_validator(
|
|
143
|
+
@field_validator("file_path")
|
|
97
144
|
@classmethod
|
|
98
145
|
def file_path_valid(cls, v: Optional[pathlib.PosixPath], info: ValidationInfo):
|
|
99
146
|
if not v:
|
|
@@ -110,7 +157,9 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
|
110
157
|
return [str(url) for url in self.references or []]
|
|
111
158
|
|
|
112
159
|
@classmethod
|
|
113
|
-
def mapNamesToSecurityContentObjects(
|
|
160
|
+
def mapNamesToSecurityContentObjects(
|
|
161
|
+
cls, v: list[str], director: Union[DirectorOutputDto, None]
|
|
162
|
+
) -> list[Self]:
|
|
114
163
|
if director is not None:
|
|
115
164
|
name_map = director.name_to_content_map
|
|
116
165
|
else:
|
|
@@ -130,7 +179,9 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
|
130
179
|
|
|
131
180
|
errors: list[str] = []
|
|
132
181
|
if len(missing_objects) > 0:
|
|
133
|
-
errors.append(
|
|
182
|
+
errors.append(
|
|
183
|
+
f"Failed to find the following '{cls.__name__}': {missing_objects}"
|
|
184
|
+
)
|
|
134
185
|
if len(mistyped_objects) > 0:
|
|
135
186
|
for mistyped_object in mistyped_objects:
|
|
136
187
|
errors.append(
|
|
@@ -142,13 +193,16 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
|
142
193
|
error_string = "\n - ".join(errors)
|
|
143
194
|
raise ValueError(
|
|
144
195
|
f"Found {len(errors)} issues when resolving references Security Content Object "
|
|
145
|
-
f"names:\n - {error_string}"
|
|
196
|
+
f"names:\n - {error_string}"
|
|
197
|
+
)
|
|
146
198
|
|
|
147
199
|
# Sort all objects sorted by name
|
|
148
200
|
return sorted(mappedObjects, key=lambda o: o.name)
|
|
149
201
|
|
|
150
202
|
@staticmethod
|
|
151
|
-
def getDeploymentFromType(
|
|
203
|
+
def getDeploymentFromType(
|
|
204
|
+
typeField: Union[str, None], info: ValidationInfo
|
|
205
|
+
) -> Deployment:
|
|
152
206
|
if typeField is None:
|
|
153
207
|
raise ValueError("'type:' field is missing from YML.")
|
|
154
208
|
|
|
@@ -157,21 +211,25 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
|
157
211
|
|
|
158
212
|
director: Optional[DirectorOutputDto] = info.context.get("output_dto", None)
|
|
159
213
|
if not director:
|
|
160
|
-
raise ValueError(
|
|
214
|
+
raise ValueError(
|
|
215
|
+
"Cannot set deployment - DirectorOutputDto not passed to Detection Constructor in context"
|
|
216
|
+
)
|
|
161
217
|
|
|
162
218
|
type_to_deployment_name_map = {
|
|
163
219
|
AnalyticsType.TTP: "ESCU Default Configuration TTP",
|
|
164
220
|
AnalyticsType.Hunting: "ESCU Default Configuration Hunting",
|
|
165
221
|
AnalyticsType.Correlation: "ESCU Default Configuration Correlation",
|
|
166
222
|
AnalyticsType.Anomaly: "ESCU Default Configuration Anomaly",
|
|
167
|
-
"Baseline": "ESCU Default Configuration Baseline"
|
|
223
|
+
"Baseline": "ESCU Default Configuration Baseline",
|
|
168
224
|
}
|
|
169
225
|
converted_type_field = type_to_deployment_name_map[typeField]
|
|
170
226
|
|
|
171
227
|
# TODO: This is clunky, but is imported here to resolve some circular import errors
|
|
172
228
|
from contentctl.objects.deployment import Deployment
|
|
173
229
|
|
|
174
|
-
deployments = Deployment.mapNamesToSecurityContentObjects(
|
|
230
|
+
deployments = Deployment.mapNamesToSecurityContentObjects(
|
|
231
|
+
[converted_type_field], director
|
|
232
|
+
)
|
|
175
233
|
if len(deployments) == 1:
|
|
176
234
|
return deployments[0]
|
|
177
235
|
elif len(deployments) == 0:
|
|
@@ -187,18 +245,19 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
|
187
245
|
|
|
188
246
|
@staticmethod
|
|
189
247
|
def get_objects_by_name(
|
|
190
|
-
names_to_find: set[str],
|
|
191
|
-
objects_to_search: list[SecurityContentObject_Abstract]
|
|
248
|
+
names_to_find: set[str], objects_to_search: list[SecurityContentObject_Abstract]
|
|
192
249
|
) -> Tuple[list[SecurityContentObject_Abstract], set[str]]:
|
|
193
250
|
raise Exception("get_objects_by_name deprecated")
|
|
194
|
-
found_objects = list(
|
|
251
|
+
found_objects = list(
|
|
252
|
+
filter(lambda obj: obj.name in names_to_find, objects_to_search)
|
|
253
|
+
)
|
|
195
254
|
found_names = set([obj.name for obj in found_objects])
|
|
196
255
|
missing_names = names_to_find - found_names
|
|
197
256
|
return found_objects, missing_names
|
|
198
257
|
|
|
199
258
|
@staticmethod
|
|
200
259
|
def create_filename_to_content_dict(
|
|
201
|
-
all_objects: list[SecurityContentObject_Abstract]
|
|
260
|
+
all_objects: list[SecurityContentObject_Abstract],
|
|
202
261
|
) -> dict[str, SecurityContentObject_Abstract]:
|
|
203
262
|
name_dict: dict[str, SecurityContentObject_Abstract] = dict()
|
|
204
263
|
for object in all_objects:
|
|
@@ -206,7 +265,9 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
|
206
265
|
# SecurityContentObject (e.g. filter macros that are created at runtime but have no
|
|
207
266
|
# actual file associated)
|
|
208
267
|
if object.file_path is None:
|
|
209
|
-
raise ValueError(
|
|
268
|
+
raise ValueError(
|
|
269
|
+
f"SecurityContentObject is missing a file_path: {object.name}"
|
|
270
|
+
)
|
|
210
271
|
name_dict[str(pathlib.Path(object.file_path))] = object
|
|
211
272
|
return name_dict
|
|
212
273
|
|
|
@@ -223,12 +284,16 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
|
223
284
|
|
|
224
285
|
def __lt__(self, other: object) -> bool:
|
|
225
286
|
if not isinstance(other, SecurityContentObject_Abstract):
|
|
226
|
-
raise Exception(
|
|
287
|
+
raise Exception(
|
|
288
|
+
f"SecurityContentObject can only be compared to each other, not to {type(other)}"
|
|
289
|
+
)
|
|
227
290
|
return self.name < other.name
|
|
228
291
|
|
|
229
292
|
def __eq__(self, other: object) -> bool:
|
|
230
293
|
if not isinstance(other, SecurityContentObject_Abstract):
|
|
231
|
-
raise Exception(
|
|
294
|
+
raise Exception(
|
|
295
|
+
f"SecurityContentObject can only be compared to each other, not to {type(other)}"
|
|
296
|
+
)
|
|
232
297
|
|
|
233
298
|
if id(self) == id(other) and self.name == other.name and self.id == other.id:
|
|
234
299
|
# Yes, this is the same object
|
|
@@ -8,6 +8,7 @@ from contentctl.objects.deployment_rba import DeploymentRBA
|
|
|
8
8
|
from contentctl.objects.deployment_slack import DeploymentSlack
|
|
9
9
|
from contentctl.objects.deployment_phantom import DeploymentPhantom
|
|
10
10
|
|
|
11
|
+
|
|
11
12
|
class AlertAction(BaseModel):
|
|
12
13
|
model_config = ConfigDict(extra="forbid")
|
|
13
14
|
email: Optional[DeploymentEmail] = None
|
|
@@ -16,26 +17,25 @@ class AlertAction(BaseModel):
|
|
|
16
17
|
slack: Optional[DeploymentSlack] = None
|
|
17
18
|
phantom: Optional[DeploymentPhantom] = None
|
|
18
19
|
|
|
19
|
-
|
|
20
20
|
@model_serializer
|
|
21
21
|
def serialize_model(self):
|
|
22
|
-
#Call serializer for parent
|
|
22
|
+
# Call serializer for parent
|
|
23
23
|
model = {}
|
|
24
24
|
|
|
25
25
|
if self.email is not None:
|
|
26
26
|
raise Exception("Email not implemented")
|
|
27
27
|
|
|
28
28
|
if self.notable is not None:
|
|
29
|
-
model[
|
|
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":
|