contentctl 4.2.2__py3-none-any.whl → 4.2.4__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/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +41 -47
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +1 -1
- contentctl/actions/detection_testing/views/DetectionTestingView.py +1 -4
- contentctl/actions/validate.py +40 -1
- contentctl/enrichments/attack_enrichment.py +6 -8
- contentctl/enrichments/cve_enrichment.py +3 -3
- contentctl/helper/splunk_app.py +263 -0
- contentctl/input/director.py +1 -1
- contentctl/input/ssa_detection_builder.py +8 -6
- contentctl/objects/abstract_security_content_objects/detection_abstract.py +362 -336
- contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +117 -103
- contentctl/objects/atomic.py +7 -10
- contentctl/objects/base_test.py +1 -1
- contentctl/objects/base_test_result.py +7 -5
- contentctl/objects/baseline_tags.py +2 -30
- contentctl/objects/config.py +5 -4
- contentctl/objects/correlation_search.py +316 -96
- contentctl/objects/data_source.py +7 -2
- contentctl/objects/detection_tags.py +128 -102
- contentctl/objects/errors.py +18 -0
- contentctl/objects/lookup.py +1 -0
- contentctl/objects/mitre_attack_enrichment.py +3 -3
- contentctl/objects/notable_event.py +20 -0
- contentctl/objects/observable.py +20 -26
- contentctl/objects/risk_analysis_action.py +2 -2
- contentctl/objects/risk_event.py +315 -0
- contentctl/objects/ssa_detection_tags.py +1 -1
- contentctl/objects/story_tags.py +2 -2
- contentctl/objects/unit_test.py +1 -9
- contentctl/output/data_source_writer.py +4 -4
- {contentctl-4.2.2.dist-info → contentctl-4.2.4.dist-info}/METADATA +5 -8
- {contentctl-4.2.2.dist-info → contentctl-4.2.4.dist-info}/RECORD +35 -31
- {contentctl-4.2.2.dist-info → contentctl-4.2.4.dist-info}/LICENSE.md +0 -0
- {contentctl-4.2.2.dist-info → contentctl-4.2.4.dist-info}/WHEEL +0 -0
- {contentctl-4.2.2.dist-info → contentctl-4.2.4.dist-info}/entry_points.txt +0 -0
|
@@ -1,49 +1,48 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from typing import TYPE_CHECKING, Self
|
|
2
|
+
from typing import TYPE_CHECKING, Self, Any
|
|
3
3
|
|
|
4
4
|
if TYPE_CHECKING:
|
|
5
|
-
from contentctl.objects.deployment import Deployment
|
|
5
|
+
from contentctl.objects.deployment import Deployment
|
|
6
6
|
from contentctl.objects.security_content_object import SecurityContentObject
|
|
7
|
-
from contentctl.objects.config import Config
|
|
8
7
|
from contentctl.input.director import DirectorOutputDto
|
|
9
|
-
|
|
8
|
+
|
|
10
9
|
from contentctl.objects.enums import AnalyticsType
|
|
11
|
-
import re
|
|
12
10
|
import abc
|
|
13
11
|
import uuid
|
|
14
12
|
import datetime
|
|
15
13
|
import pprint
|
|
16
|
-
from pydantic import
|
|
14
|
+
from pydantic import (
|
|
15
|
+
BaseModel,
|
|
16
|
+
field_validator,
|
|
17
|
+
Field,
|
|
18
|
+
ValidationInfo,
|
|
19
|
+
FilePath,
|
|
20
|
+
HttpUrl,
|
|
21
|
+
NonNegativeInt,
|
|
22
|
+
ConfigDict,
|
|
23
|
+
model_serializer
|
|
24
|
+
)
|
|
17
25
|
from typing import Tuple, Optional, List, Union
|
|
18
26
|
import pathlib
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
27
|
|
|
23
28
|
|
|
24
29
|
NO_FILE_NAME = "NO_FILE_NAME"
|
|
25
30
|
|
|
26
31
|
|
|
27
32
|
class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
28
|
-
model_config = ConfigDict(use_enum_values=True,validate_default=True)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
# version: NonNegativeInt = ...
|
|
33
|
-
# id: uuid.UUID = Field(default_factory=uuid.uuid4) #we set a default here until all content has a uuid
|
|
34
|
-
# description: str = Field(...,max_length=1000)
|
|
35
|
-
# file_path: FilePath = Field(...)
|
|
36
|
-
# references: Optional[List[HttpUrl]] = None
|
|
37
|
-
|
|
38
|
-
name: str = Field("NO_NAME")
|
|
39
|
-
author: str = Field("Content Author",max_length=255)
|
|
33
|
+
model_config = ConfigDict(use_enum_values=True, validate_default=True)
|
|
34
|
+
|
|
35
|
+
name: str = Field(...)
|
|
36
|
+
author: str = Field("Content Author", max_length=255)
|
|
40
37
|
date: datetime.date = Field(datetime.date.today())
|
|
41
38
|
version: NonNegativeInt = 1
|
|
42
|
-
id: uuid.UUID = Field(default_factory=uuid.uuid4)
|
|
43
|
-
description: str = Field("Enter Description Here",max_length=10000)
|
|
39
|
+
id: uuid.UUID = Field(default_factory=uuid.uuid4) # we set a default here until all content has a uuid
|
|
40
|
+
description: str = Field("Enter Description Here", max_length=10000)
|
|
44
41
|
file_path: Optional[FilePath] = None
|
|
45
42
|
references: Optional[List[HttpUrl]] = None
|
|
46
43
|
|
|
44
|
+
def model_post_init(self, __context: Any) -> None:
|
|
45
|
+
self.ensureFileNameMatchesSearchName()
|
|
47
46
|
|
|
48
47
|
@model_serializer
|
|
49
48
|
def serialize_model(self):
|
|
@@ -58,33 +57,32 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
|
58
57
|
}
|
|
59
58
|
|
|
60
59
|
@staticmethod
|
|
61
|
-
def objectListToNameList(objects:list[SecurityContentObject]
|
|
62
|
-
return [object.getName(
|
|
60
|
+
def objectListToNameList(objects: list[SecurityContentObject]) -> list[str]:
|
|
61
|
+
return [object.getName() for object in objects]
|
|
63
62
|
|
|
64
63
|
# This function is overloadable by specific types if they want to redefine names, for example
|
|
65
64
|
# to have the format ESCU - NAME - Rule (config.tag - self.name - Rule)
|
|
66
|
-
def getName(self
|
|
65
|
+
def getName(self) -> str:
|
|
67
66
|
return self.name
|
|
68
67
|
|
|
69
|
-
|
|
70
68
|
@classmethod
|
|
71
|
-
def contentNameToFileName(cls, content_name:str)->str:
|
|
69
|
+
def contentNameToFileName(cls, content_name: str) -> str:
|
|
72
70
|
return content_name \
|
|
73
71
|
.replace(' ', '_') \
|
|
74
|
-
.replace('-','_') \
|
|
75
|
-
.replace('.','_') \
|
|
76
|
-
.replace('/','_') \
|
|
72
|
+
.replace('-', '_') \
|
|
73
|
+
.replace('.', '_') \
|
|
74
|
+
.replace('/', '_') \
|
|
77
75
|
.lower() + ".yml"
|
|
78
76
|
|
|
79
|
-
|
|
80
|
-
@model_validator(mode="after")
|
|
81
77
|
def ensureFileNameMatchesSearchName(self):
|
|
82
78
|
file_name = self.contentNameToFileName(self.name)
|
|
83
|
-
|
|
79
|
+
|
|
84
80
|
if (self.file_path is not None and file_name != self.file_path.name):
|
|
85
|
-
raise ValueError(
|
|
86
|
-
|
|
87
|
-
|
|
81
|
+
raise ValueError(
|
|
82
|
+
f"The file name MUST be based off the content 'name' field:\n"
|
|
83
|
+
f"\t- Expected File Name: {file_name}\n"
|
|
84
|
+
f"\t- Actual File Name : {self.file_path.name}"
|
|
85
|
+
)
|
|
88
86
|
|
|
89
87
|
return self
|
|
90
88
|
|
|
@@ -92,99 +90,120 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
|
92
90
|
@classmethod
|
|
93
91
|
def file_path_valid(cls, v: Optional[pathlib.PosixPath], info: ValidationInfo):
|
|
94
92
|
if not v:
|
|
95
|
-
#It's possible that the object has no file path - for example filter macros that are created at runtime
|
|
93
|
+
# It's possible that the object has no file path - for example filter macros that are created at runtime
|
|
96
94
|
return v
|
|
97
95
|
if not v.name.endswith(".yml"):
|
|
98
|
-
raise ValueError(
|
|
96
|
+
raise ValueError(
|
|
97
|
+
"All Security Content Objects must be YML files and end in .yml. The following"
|
|
98
|
+
f" file does not: '{v}'"
|
|
99
|
+
)
|
|
99
100
|
return v
|
|
100
101
|
|
|
101
|
-
def getReferencesListForJson(self)->List[str]:
|
|
102
|
+
def getReferencesListForJson(self) -> List[str]:
|
|
102
103
|
return [str(url) for url in self.references or []]
|
|
103
|
-
|
|
104
|
+
|
|
104
105
|
@classmethod
|
|
105
|
-
def mapNamesToSecurityContentObjects(cls, v: list[str], director:Union[DirectorOutputDto,None])->list[Self]:
|
|
106
|
+
def mapNamesToSecurityContentObjects(cls, v: list[str], director: Union[DirectorOutputDto, None]) -> list[Self]:
|
|
106
107
|
if director is not None:
|
|
107
108
|
name_map = director.name_to_content_map
|
|
108
109
|
else:
|
|
109
110
|
name_map = {}
|
|
110
|
-
|
|
111
|
-
|
|
112
111
|
|
|
113
112
|
mappedObjects: list[Self] = []
|
|
114
113
|
mistyped_objects: list[SecurityContentObject_Abstract] = []
|
|
115
114
|
missing_objects: list[str] = []
|
|
116
115
|
for object_name in v:
|
|
117
|
-
found_object = name_map.get(object_name,None)
|
|
116
|
+
found_object = name_map.get(object_name, None)
|
|
118
117
|
if not found_object:
|
|
119
118
|
missing_objects.append(object_name)
|
|
120
|
-
elif not isinstance(found_object,cls):
|
|
119
|
+
elif not isinstance(found_object, cls):
|
|
121
120
|
mistyped_objects.append(found_object)
|
|
122
121
|
else:
|
|
123
122
|
mappedObjects.append(found_object)
|
|
124
|
-
|
|
125
|
-
errors:list[str] = []
|
|
123
|
+
|
|
124
|
+
errors: list[str] = []
|
|
126
125
|
if len(missing_objects) > 0:
|
|
127
126
|
errors.append(f"Failed to find the following '{cls.__name__}': {missing_objects}")
|
|
128
127
|
if len(mistyped_objects) > 0:
|
|
129
128
|
for mistyped_object in mistyped_objects:
|
|
130
|
-
errors.append(
|
|
131
|
-
|
|
129
|
+
errors.append(
|
|
130
|
+
f"'{mistyped_object.name}' expected to have type '{cls}', but actually "
|
|
131
|
+
f"had type '{type(mistyped_object)}'"
|
|
132
|
+
)
|
|
133
|
+
|
|
132
134
|
if len(errors) > 0:
|
|
133
135
|
error_string = "\n - ".join(errors)
|
|
134
|
-
raise ValueError(
|
|
135
|
-
|
|
136
|
-
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"Found {len(errors)} issues when resolving references Security Content Object "
|
|
138
|
+
f"names:\n - {error_string}")
|
|
139
|
+
|
|
140
|
+
# Sort all objects sorted by name
|
|
137
141
|
return sorted(mappedObjects, key=lambda o: o.name)
|
|
138
142
|
|
|
139
143
|
@staticmethod
|
|
140
|
-
def getDeploymentFromType(typeField:Union[str,None], info:ValidationInfo)->Deployment:
|
|
144
|
+
def getDeploymentFromType(typeField: Union[str, None], info: ValidationInfo) -> Deployment:
|
|
141
145
|
if typeField is None:
|
|
142
146
|
raise ValueError("'type:' field is missing from YML.")
|
|
143
|
-
|
|
147
|
+
|
|
148
|
+
if info.context is None:
|
|
149
|
+
raise ValueError("ValidationInfo.context unexpectedly null")
|
|
150
|
+
|
|
151
|
+
director: Optional[DirectorOutputDto] = info.context.get("output_dto", None)
|
|
144
152
|
if not director:
|
|
145
153
|
raise ValueError("Cannot set deployment - DirectorOutputDto not passed to Detection Constructor in context")
|
|
146
|
-
|
|
147
|
-
type_to_deployment_name_map = {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
154
|
+
|
|
155
|
+
type_to_deployment_name_map = {
|
|
156
|
+
AnalyticsType.TTP.value: "ESCU Default Configuration TTP",
|
|
157
|
+
AnalyticsType.Hunting.value: "ESCU Default Configuration Hunting",
|
|
158
|
+
AnalyticsType.Correlation.value: "ESCU Default Configuration Correlation",
|
|
159
|
+
AnalyticsType.Anomaly.value: "ESCU Default Configuration Anomaly",
|
|
160
|
+
"Baseline": "ESCU Default Configuration Baseline"
|
|
152
161
|
}
|
|
153
162
|
converted_type_field = type_to_deployment_name_map[typeField]
|
|
154
|
-
|
|
155
|
-
#TODO: This is clunky, but is imported here to resolve some circular import errors
|
|
156
|
-
from contentctl.objects.deployment import Deployment
|
|
163
|
+
|
|
164
|
+
# TODO: This is clunky, but is imported here to resolve some circular import errors
|
|
165
|
+
from contentctl.objects.deployment import Deployment
|
|
157
166
|
|
|
158
167
|
deployments = Deployment.mapNamesToSecurityContentObjects([converted_type_field], director)
|
|
159
168
|
if len(deployments) == 1:
|
|
160
169
|
return deployments[0]
|
|
161
170
|
elif len(deployments) == 0:
|
|
162
|
-
raise ValueError(
|
|
163
|
-
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"Failed to find Deployment for type '{converted_type_field}' "
|
|
173
|
+
f"from possible {[deployment.type for deployment in director.deployments]}"
|
|
174
|
+
)
|
|
164
175
|
else:
|
|
165
|
-
raise ValueError(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
176
|
+
raise ValueError(
|
|
177
|
+
f"Found more than 1 ({len(deployments)}) Deployment for type '{converted_type_field}' "
|
|
178
|
+
f"from possible {[deployment.type for deployment in director.deployments]}"
|
|
179
|
+
)
|
|
170
180
|
|
|
171
181
|
@staticmethod
|
|
172
|
-
def get_objects_by_name(
|
|
182
|
+
def get_objects_by_name(
|
|
183
|
+
names_to_find: set[str],
|
|
184
|
+
objects_to_search: list[SecurityContentObject_Abstract]
|
|
185
|
+
) -> Tuple[list[SecurityContentObject_Abstract], set[str]]:
|
|
173
186
|
raise Exception("get_objects_by_name deprecated")
|
|
174
187
|
found_objects = list(filter(lambda obj: obj.name in names_to_find, objects_to_search))
|
|
175
188
|
found_names = set([obj.name for obj in found_objects])
|
|
176
189
|
missing_names = names_to_find - found_names
|
|
177
|
-
return found_objects,missing_names
|
|
178
|
-
|
|
190
|
+
return found_objects, missing_names
|
|
191
|
+
|
|
179
192
|
@staticmethod
|
|
180
|
-
def create_filename_to_content_dict(
|
|
181
|
-
|
|
193
|
+
def create_filename_to_content_dict(
|
|
194
|
+
all_objects: list[SecurityContentObject_Abstract]
|
|
195
|
+
) -> dict[str, SecurityContentObject_Abstract]:
|
|
196
|
+
name_dict: dict[str, SecurityContentObject_Abstract] = dict()
|
|
182
197
|
for object in all_objects:
|
|
198
|
+
# If file_path is None, this function has been called on an inappropriate
|
|
199
|
+
# SecurityContentObject (e.g. filter macros that are created at runtime but have no
|
|
200
|
+
# actual file associated)
|
|
201
|
+
if object.file_path is None:
|
|
202
|
+
raise ValueError(f"SecurityContentObject is missing a file_path: {object.name}")
|
|
183
203
|
name_dict[str(pathlib.Path(object.file_path))] = object
|
|
184
204
|
return name_dict
|
|
185
205
|
|
|
186
|
-
|
|
187
|
-
def __repr__(self)->str:
|
|
206
|
+
def __repr__(self) -> str:
|
|
188
207
|
# Just use the model_dump functionality that
|
|
189
208
|
# has already been written. This loses some of the
|
|
190
209
|
# richness where objects reference themselves, but
|
|
@@ -192,40 +211,35 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
|
192
211
|
m = self.model_dump()
|
|
193
212
|
return pprint.pformat(m, indent=3)
|
|
194
213
|
|
|
195
|
-
def __str__(self)->str:
|
|
196
|
-
return
|
|
214
|
+
def __str__(self) -> str:
|
|
215
|
+
return self.__repr__()
|
|
197
216
|
|
|
198
|
-
def __lt__(self, other:object)->bool:
|
|
199
|
-
if not isinstance(other,SecurityContentObject_Abstract):
|
|
217
|
+
def __lt__(self, other: object) -> bool:
|
|
218
|
+
if not isinstance(other, SecurityContentObject_Abstract):
|
|
200
219
|
raise Exception(f"SecurityContentObject can only be compared to each other, not to {type(other)}")
|
|
201
|
-
return self.name < other.name
|
|
220
|
+
return self.name < other.name
|
|
202
221
|
|
|
203
|
-
def __eq__(self, other:object)->bool:
|
|
204
|
-
if not isinstance(other,SecurityContentObject_Abstract):
|
|
222
|
+
def __eq__(self, other: object) -> bool:
|
|
223
|
+
if not isinstance(other, SecurityContentObject_Abstract):
|
|
205
224
|
raise Exception(f"SecurityContentObject can only be compared to each other, not to {type(other)}")
|
|
206
|
-
|
|
225
|
+
|
|
207
226
|
if id(self) == id(other) and self.name == other.name and self.id == other.id:
|
|
208
227
|
# Yes, this is the same object
|
|
209
228
|
return True
|
|
210
|
-
|
|
229
|
+
|
|
211
230
|
elif id(self) == id(other) or self.name == other.name or self.id == other.id:
|
|
212
|
-
raise Exception(
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
231
|
+
raise Exception(
|
|
232
|
+
"Attempted to compare two SecurityContentObjects, but their fields indicate they "
|
|
233
|
+
"were not globally unique:"
|
|
234
|
+
f"\n\tid(obj1) : {id(self)}"
|
|
235
|
+
f"\n\tid(obj2) : {id(other)}"
|
|
236
|
+
f"\n\tobj1.name : {self.name}"
|
|
237
|
+
f"\n\tobj2.name : {other.name}"
|
|
238
|
+
f"\n\tobj1.id : {self.id}"
|
|
239
|
+
f"\n\tobj2.id : {other.id}"
|
|
240
|
+
)
|
|
219
241
|
else:
|
|
220
242
|
return False
|
|
221
|
-
|
|
243
|
+
|
|
222
244
|
def __hash__(self) -> NonNegativeInt:
|
|
223
245
|
return id(self)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
contentctl/objects/atomic.py
CHANGED
|
@@ -3,10 +3,7 @@ from contentctl.input.yml_reader import YmlReader
|
|
|
3
3
|
from pydantic import BaseModel, model_validator, ConfigDict, FilePath, UUID4
|
|
4
4
|
from typing import List, Optional, Dict, Union, Self
|
|
5
5
|
import pathlib
|
|
6
|
-
|
|
7
|
-
# Alternatively, we can use
|
|
8
|
-
# class SupportedPlatform(str, enum.Enum):
|
|
9
|
-
# or install the StrEnum library from pip
|
|
6
|
+
|
|
10
7
|
|
|
11
8
|
from enum import StrEnum, auto
|
|
12
9
|
|
|
@@ -48,7 +45,7 @@ class AtomicExecutor(BaseModel):
|
|
|
48
45
|
cleanup_command: Optional[str] = None
|
|
49
46
|
|
|
50
47
|
@model_validator(mode='after')
|
|
51
|
-
def ensure_mutually_exclusive_fields(self)->
|
|
48
|
+
def ensure_mutually_exclusive_fields(self)->Self:
|
|
52
49
|
if self.command is not None and self.steps is not None:
|
|
53
50
|
raise ValueError("command and steps cannot both be defined in the executor section. Exactly one must be defined.")
|
|
54
51
|
elif self.command is None and self.steps is None:
|
|
@@ -88,7 +85,7 @@ class AtomicTest(BaseModel):
|
|
|
88
85
|
dependency_executor_name: Optional[DependencyExecutorType] = None
|
|
89
86
|
|
|
90
87
|
@staticmethod
|
|
91
|
-
def AtomicTestWhenEnrichmentIsDisabled(auto_generated_guid: UUID4)->
|
|
88
|
+
def AtomicTestWhenEnrichmentIsDisabled(auto_generated_guid: UUID4) -> AtomicTest:
|
|
92
89
|
return AtomicTest(name="Placeholder Atomic Test (enrichment disabled)",
|
|
93
90
|
auto_generated_guid=auto_generated_guid,
|
|
94
91
|
description="This is a placeholder AtomicTest. Because enrichments were not enabled, it has not been validated against the real Atomic Red Team Repo.",
|
|
@@ -97,17 +94,17 @@ class AtomicTest(BaseModel):
|
|
|
97
94
|
command="Placeholder command (enrichment disabled)"))
|
|
98
95
|
|
|
99
96
|
@staticmethod
|
|
100
|
-
def AtomicTestWhenTestIsMissing(auto_generated_guid: UUID4)->
|
|
97
|
+
def AtomicTestWhenTestIsMissing(auto_generated_guid: UUID4) -> AtomicTest:
|
|
101
98
|
return AtomicTest(name="Missing Atomic",
|
|
102
99
|
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
|
|
100
|
+
description="This is a placeholder AtomicTest. Either the auto_generated_guid is incorrect or it there was an exception while parsing its AtomicFile.",
|
|
104
101
|
supported_platforms=[],
|
|
105
102
|
executor=AtomicExecutor(name="Placeholder Executor (failed to find auto_generated_guid)",
|
|
106
103
|
command="Placeholder command (failed to find auto_generated_guid)"))
|
|
107
104
|
|
|
108
105
|
|
|
109
106
|
@classmethod
|
|
110
|
-
def getAtomicByAtomicGuid(cls, guid: UUID4, all_atomics:
|
|
107
|
+
def getAtomicByAtomicGuid(cls, guid: UUID4, all_atomics:list[AtomicTest] | None)->AtomicTest:
|
|
111
108
|
if all_atomics is None:
|
|
112
109
|
return AtomicTest.AtomicTestWhenEnrichmentIsDisabled(guid)
|
|
113
110
|
matching_atomics = [atomic for atomic in all_atomics if atomic.auto_generated_guid == guid]
|
|
@@ -152,7 +149,7 @@ class AtomicTest(BaseModel):
|
|
|
152
149
|
return atomic_file
|
|
153
150
|
|
|
154
151
|
@classmethod
|
|
155
|
-
def getAtomicTestsFromArtRepo(cls, repo_path:pathlib.Path, enabled:bool=True)->
|
|
152
|
+
def getAtomicTestsFromArtRepo(cls, repo_path:pathlib.Path, enabled:bool=True)->list[AtomicTest] | None:
|
|
156
153
|
# Get all the atomic files. Note that if the ART repo is not found, we will not throw an error,
|
|
157
154
|
# but will not have any atomics. This means that if atomic_guids are referenced during validation,
|
|
158
155
|
# validation for those detections will fail
|
contentctl/objects/base_test.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
from typing import Union
|
|
1
|
+
from typing import Union, Any
|
|
2
2
|
from enum import Enum
|
|
3
3
|
|
|
4
4
|
from pydantic import BaseModel
|
|
@@ -7,6 +7,8 @@ from splunklib.data import Record
|
|
|
7
7
|
from contentctl.helper.utils import Utils
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
# TODO (PEX-432): add status "UNSET" so that we can make sure the result is always of this enum
|
|
11
|
+
# type; remove mypy ignores associated w/ these typing issues once we do
|
|
10
12
|
class TestResultStatus(str, Enum):
|
|
11
13
|
"""Enum for test status (e.g. pass/fail)"""
|
|
12
14
|
# Test failed (detection did NOT fire appropriately)
|
|
@@ -26,7 +28,7 @@ class TestResultStatus(str, Enum):
|
|
|
26
28
|
return self.value
|
|
27
29
|
|
|
28
30
|
|
|
29
|
-
# TODO (
|
|
31
|
+
# TODO (#225): add validator to BaseTestResult which makes a lack of exception incompatible
|
|
30
32
|
# with status ERROR
|
|
31
33
|
class BaseTestResult(BaseModel):
|
|
32
34
|
"""
|
|
@@ -94,7 +96,7 @@ class BaseTestResult(BaseModel):
|
|
|
94
96
|
"success", "exception", "message", "sid_link", "status", "duration", "wait_duration"
|
|
95
97
|
],
|
|
96
98
|
job_fields: list[str] = ["search", "resultCount", "runDuration"],
|
|
97
|
-
) -> dict:
|
|
99
|
+
) -> dict[str, Any]:
|
|
98
100
|
"""
|
|
99
101
|
Aggregates a dictionary summarizing the test result model
|
|
100
102
|
:param model_fields: the fields of the test result to gather
|
|
@@ -102,7 +104,7 @@ class BaseTestResult(BaseModel):
|
|
|
102
104
|
:returns: a dict summary
|
|
103
105
|
"""
|
|
104
106
|
# Init the summary dict
|
|
105
|
-
summary_dict = {}
|
|
107
|
+
summary_dict: dict[str, Any] = {}
|
|
106
108
|
|
|
107
109
|
# Grab the fields required
|
|
108
110
|
for field in model_fields:
|
|
@@ -122,7 +124,7 @@ class BaseTestResult(BaseModel):
|
|
|
122
124
|
# Grab the job content fields required
|
|
123
125
|
for field in job_fields:
|
|
124
126
|
if self.job_content is not None:
|
|
125
|
-
value = self.job_content.get(field, None)
|
|
127
|
+
value: Any = self.job_content.get(field, None) # type: ignore
|
|
126
128
|
|
|
127
129
|
# convert runDuration to a fixed width string representation of a float
|
|
128
130
|
if field == "runDuration":
|
|
@@ -17,6 +17,7 @@ if TYPE_CHECKING:
|
|
|
17
17
|
class BaselineTags(BaseModel):
|
|
18
18
|
analytic_story: list[Story] = Field(...)
|
|
19
19
|
#deployment: Deployment = Field('SET_IN_GET_DEPLOYMENT_FUNCTION')
|
|
20
|
+
# TODO (#223): can we remove str from the possible types here?
|
|
20
21
|
detections: List[Union[Detection,str]] = Field(...)
|
|
21
22
|
product: list[SecurityContentProductName] = Field(...,min_length=1)
|
|
22
23
|
required_fields: List[str] = Field(...,min_length=1)
|
|
@@ -42,33 +43,4 @@ class BaselineTags(BaseModel):
|
|
|
42
43
|
|
|
43
44
|
|
|
44
45
|
#return the model
|
|
45
|
-
return model
|
|
46
|
-
|
|
47
|
-
def replaceDetectionNameWithDetectionObject(self, detection:Detection)->bool:
|
|
48
|
-
|
|
49
|
-
pass
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
# @field_validator("deployment", mode="before")
|
|
55
|
-
# def getDeployment(cls, v:Any, info:ValidationInfo)->Deployment:
|
|
56
|
-
# if v != 'SET_IN_GET_DEPLOYMENT_FUNCTION':
|
|
57
|
-
# print(f"Deployment defined in YML: {v}")
|
|
58
|
-
# return v
|
|
59
|
-
|
|
60
|
-
# director: Optional[DirectorOutputDto] = info.context.get("output_dto",None)
|
|
61
|
-
# if not director:
|
|
62
|
-
# raise ValueError("Cannot set deployment - DirectorOutputDto not passed to Detection Constructor in context")
|
|
63
|
-
|
|
64
|
-
# typeField = "Baseline"
|
|
65
|
-
# deps = [deployment for deployment in director.deployments if deployment.type == typeField]
|
|
66
|
-
# if len(deps) == 1:
|
|
67
|
-
# return deps[0]
|
|
68
|
-
# elif len(deps) == 0:
|
|
69
|
-
# raise ValueError(f"Failed to find Deployment for type '{typeField}' "\
|
|
70
|
-
# f"from possible {[deployment.type for deployment in director.deployments]}")
|
|
71
|
-
# else:
|
|
72
|
-
# raise ValueError(f"Found more than 1 ({len(deps)}) Deployment for type '{typeField}' "\
|
|
73
|
-
# f"from possible {[deployment.type for deployment in director.deployments]}")
|
|
74
|
-
|
|
46
|
+
return model
|
contentctl/objects/config.py
CHANGED
|
@@ -176,6 +176,7 @@ class validate(Config_Base):
|
|
|
176
176
|
build_app: bool = Field(default=True, description="Should an app be built and output in the build_path?")
|
|
177
177
|
build_api: bool = Field(default=False, description="Should api objects be built and output in the build_path?")
|
|
178
178
|
build_ssa: bool = Field(default=False, description="Should ssa objects be built and output in the build_path?")
|
|
179
|
+
data_source_TA_validation: bool = Field(default=False, description="Validate latest TA information from Splunkbase")
|
|
179
180
|
|
|
180
181
|
def getAtomicRedTeamRepoPath(self, atomic_red_team_repo_name:str = "atomic-red-team"):
|
|
181
182
|
return self.path/atomic_red_team_repo_name
|
|
@@ -601,13 +602,13 @@ class test_common(build):
|
|
|
601
602
|
|
|
602
603
|
|
|
603
604
|
def getLocalAppDir(self)->pathlib.Path:
|
|
604
|
-
#docker really wants
|
|
605
|
+
# docker really wants absolute paths
|
|
605
606
|
path = self.path / "apps"
|
|
606
607
|
return path.absolute()
|
|
607
608
|
|
|
608
609
|
def getContainerAppDir(self)->pathlib.Path:
|
|
609
|
-
#docker really wants
|
|
610
|
-
return pathlib.Path("/tmp/apps")
|
|
610
|
+
# docker really wants absolute paths
|
|
611
|
+
return pathlib.Path("/tmp/apps")
|
|
611
612
|
|
|
612
613
|
def enterpriseSecurityInApps(self)->bool:
|
|
613
614
|
|
|
@@ -739,7 +740,7 @@ class test(test_common):
|
|
|
739
740
|
if path.startswith(SPLUNKBASE_URL):
|
|
740
741
|
container_paths.append(path)
|
|
741
742
|
else:
|
|
742
|
-
container_paths.append(
|
|
743
|
+
container_paths.append((self.getContainerAppDir()/pathlib.Path(path).name).as_posix())
|
|
743
744
|
|
|
744
745
|
return ','.join(container_paths)
|
|
745
746
|
|