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