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,48 +1,66 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
import uuid
|
|
3
3
|
from typing import TYPE_CHECKING, List, Optional, Annotated, Union
|
|
4
|
-
from pydantic import
|
|
4
|
+
from pydantic import (
|
|
5
|
+
BaseModel,
|
|
6
|
+
Field,
|
|
7
|
+
NonNegativeInt,
|
|
8
|
+
PositiveInt,
|
|
9
|
+
computed_field,
|
|
10
|
+
UUID4,
|
|
11
|
+
HttpUrl,
|
|
12
|
+
ConfigDict,
|
|
13
|
+
field_validator,
|
|
14
|
+
ValidationInfo,
|
|
15
|
+
model_serializer,
|
|
16
|
+
model_validator
|
|
17
|
+
)
|
|
5
18
|
from contentctl.objects.story import Story
|
|
6
19
|
if TYPE_CHECKING:
|
|
7
20
|
from contentctl.input.director import DirectorOutputDto
|
|
8
21
|
|
|
9
|
-
|
|
10
|
-
|
|
11
22
|
from contentctl.objects.mitre_attack_enrichment import MitreAttackEnrichment
|
|
12
|
-
from contentctl.objects.constants import
|
|
23
|
+
from contentctl.objects.constants import ATTACK_TACTICS_KILLCHAIN_MAPPING
|
|
13
24
|
from contentctl.objects.observable import Observable
|
|
14
|
-
from contentctl.objects.enums import
|
|
25
|
+
from contentctl.objects.enums import (
|
|
26
|
+
Cis18Value,
|
|
27
|
+
AssetType,
|
|
28
|
+
SecurityDomain,
|
|
29
|
+
RiskSeverity,
|
|
30
|
+
KillChainPhase,
|
|
31
|
+
NistCategory,
|
|
32
|
+
RiskLevel,
|
|
33
|
+
SecurityContentProductName
|
|
34
|
+
)
|
|
15
35
|
from contentctl.objects.atomic import AtomicTest
|
|
16
36
|
|
|
17
37
|
|
|
18
|
-
|
|
19
38
|
class DetectionTags(BaseModel):
|
|
20
39
|
# detection spec
|
|
21
|
-
model_config = ConfigDict(use_enum_values=True,validate_default=False)
|
|
40
|
+
model_config = ConfigDict(use_enum_values=True, validate_default=False)
|
|
22
41
|
analytic_story: list[Story] = Field(...)
|
|
23
42
|
asset_type: AssetType = Field(...)
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
43
|
+
|
|
44
|
+
confidence: NonNegativeInt = Field(..., le=100)
|
|
45
|
+
impact: NonNegativeInt = Field(..., le=100)
|
|
46
|
+
|
|
28
47
|
@computed_field
|
|
29
48
|
@property
|
|
30
|
-
def risk_score(self)->int:
|
|
49
|
+
def risk_score(self) -> int:
|
|
31
50
|
return round((self.confidence * self.impact)/100)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
mitre_attack_id: List[Annotated[str, Field(pattern="^T[0-9]{4}(.[0-9]{3})?$")]] = []
|
|
51
|
+
|
|
52
|
+
mitre_attack_id: List[Annotated[str, Field(pattern=r"^T\d{4}(.\d{3})?$")]] = []
|
|
35
53
|
nist: list[NistCategory] = []
|
|
36
54
|
observable: List[Observable] = []
|
|
37
|
-
message:
|
|
38
|
-
product: list[SecurityContentProductName] = Field(...,min_length=1)
|
|
55
|
+
message: str = Field(...)
|
|
56
|
+
product: list[SecurityContentProductName] = Field(..., min_length=1)
|
|
39
57
|
required_fields: list[str] = Field(min_length=1)
|
|
40
|
-
|
|
58
|
+
|
|
41
59
|
security_domain: SecurityDomain = Field(...)
|
|
42
60
|
|
|
43
61
|
@computed_field
|
|
44
62
|
@property
|
|
45
|
-
def risk_severity(self)->RiskSeverity:
|
|
63
|
+
def risk_severity(self) -> RiskSeverity:
|
|
46
64
|
if self.risk_score >= 80:
|
|
47
65
|
return RiskSeverity('high')
|
|
48
66
|
elif (self.risk_score >= 50 and self.risk_score <= 79):
|
|
@@ -50,83 +68,81 @@ class DetectionTags(BaseModel):
|
|
|
50
68
|
else:
|
|
51
69
|
return RiskSeverity('low')
|
|
52
70
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
cve: List[Annotated[str, "^CVE-[1|2][0-9]{3}-[0-9]+$"]] = []
|
|
71
|
+
cve: List[Annotated[str, r"^CVE-[1|2]\d{3}-\d+$"]] = []
|
|
56
72
|
atomic_guid: List[AtomicTest] = []
|
|
57
73
|
drilldown_search: Optional[str] = None
|
|
58
74
|
|
|
59
|
-
|
|
60
75
|
# enrichment
|
|
61
|
-
mitre_attack_enrichments: List[MitreAttackEnrichment] = Field([],validate_default=True)
|
|
62
|
-
confidence_id: Optional[PositiveInt] = Field(None,ge=1,le=3)
|
|
63
|
-
impact_id: Optional[PositiveInt] = Field(None,ge=1,le=5)
|
|
76
|
+
mitre_attack_enrichments: List[MitreAttackEnrichment] = Field([], validate_default=True)
|
|
77
|
+
confidence_id: Optional[PositiveInt] = Field(None, ge=1, le=3)
|
|
78
|
+
impact_id: Optional[PositiveInt] = Field(None, ge=1, le=5)
|
|
64
79
|
# context_ids: list = None
|
|
65
|
-
risk_level_id: Optional[NonNegativeInt] = Field(None,le=4)
|
|
80
|
+
risk_level_id: Optional[NonNegativeInt] = Field(None, le=4)
|
|
66
81
|
risk_level: Optional[RiskLevel] = None
|
|
67
|
-
#observable_str: str = None
|
|
82
|
+
# observable_str: str = None
|
|
68
83
|
evidence_str: Optional[str] = None
|
|
69
84
|
|
|
70
85
|
@computed_field
|
|
71
86
|
@property
|
|
72
|
-
def kill_chain_phases(self)->list[KillChainPhase]:
|
|
73
|
-
|
|
74
|
-
return []
|
|
75
|
-
phases:set[KillChainPhase] = set()
|
|
87
|
+
def kill_chain_phases(self) -> list[KillChainPhase]:
|
|
88
|
+
phases: set[KillChainPhase] = set()
|
|
76
89
|
for enrichment in self.mitre_attack_enrichments:
|
|
77
90
|
for tactic in enrichment.mitre_attack_tactics:
|
|
78
91
|
phase = KillChainPhase(ATTACK_TACTICS_KILLCHAIN_MAPPING[tactic])
|
|
79
92
|
phases.add(phase)
|
|
80
93
|
return sorted(list(phases))
|
|
81
|
-
|
|
82
|
-
#enum is intentionally Cis18 even though field is named cis20 for legacy reasons
|
|
94
|
+
|
|
95
|
+
# enum is intentionally Cis18 even though field is named cis20 for legacy reasons
|
|
83
96
|
@computed_field
|
|
84
97
|
@property
|
|
85
|
-
def cis20(self)->list[Cis18Value]:
|
|
98
|
+
def cis20(self) -> list[Cis18Value]:
|
|
86
99
|
if self.security_domain == SecurityDomain.NETWORK:
|
|
87
100
|
return [Cis18Value.CIS_13]
|
|
88
101
|
else:
|
|
89
102
|
return [Cis18Value.CIS_10]
|
|
90
103
|
|
|
91
|
-
|
|
92
104
|
research_site_url: Optional[HttpUrl] = None
|
|
93
105
|
event_schema: str = "ocsf"
|
|
106
|
+
# TODO (#221): mappings should be fleshed out into a proper class
|
|
94
107
|
mappings: Optional[List] = None
|
|
95
|
-
#annotations: Optional[dict] = None
|
|
108
|
+
# annotations: Optional[dict] = None
|
|
96
109
|
manual_test: Optional[str] = None
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
|
|
99
111
|
# The following validator is temporarily disabled pending further discussions
|
|
100
112
|
# @validator('message')
|
|
101
113
|
# def validate_message(cls,v,values):
|
|
102
|
-
|
|
114
|
+
|
|
103
115
|
# observables:list[Observable] = values.get("observable",[])
|
|
104
116
|
# observable_names = set([o.name for o in observables])
|
|
105
117
|
# #find all of the observables used in the message by name
|
|
106
118
|
# name_match_regex = r"\$([^\s.]*)\$"
|
|
107
|
-
|
|
119
|
+
|
|
108
120
|
# message_observables = set()
|
|
109
121
|
|
|
110
|
-
# #Make sure that all observable names in
|
|
122
|
+
# #Make sure that all observable names in
|
|
111
123
|
# for match in re.findall(name_match_regex, v):
|
|
112
124
|
# #Remove
|
|
113
125
|
# match_without_dollars = match.replace("$", "")
|
|
114
126
|
# message_observables.add(match_without_dollars)
|
|
115
|
-
|
|
116
127
|
|
|
117
128
|
# missing_observables = message_observables - observable_names
|
|
118
129
|
# unused_observables = observable_names - message_observables
|
|
119
130
|
# if len(missing_observables) > 0:
|
|
120
|
-
# raise ValueError(
|
|
121
|
-
|
|
131
|
+
# raise ValueError(
|
|
132
|
+
# "The following observables are referenced in the message, but were not declared as"
|
|
133
|
+
# f" observables: {missing_observables}"
|
|
134
|
+
# )
|
|
135
|
+
|
|
122
136
|
# if len(unused_observables) > 0:
|
|
123
|
-
# raise ValueError(
|
|
137
|
+
# raise ValueError(
|
|
138
|
+
# "The following observables were declared, but are not referenced in the message:"
|
|
139
|
+
# f" {unused_observables}"
|
|
140
|
+
# )
|
|
124
141
|
# return v
|
|
125
142
|
|
|
126
|
-
|
|
127
143
|
@model_serializer
|
|
128
144
|
def serialize_model(self):
|
|
129
|
-
#Since this field has no parent, there is no need to call super() serialization function
|
|
145
|
+
# Since this field has no parent, there is no need to call super() serialization function
|
|
130
146
|
return {
|
|
131
147
|
"analytic_story": [story.name for story in self.analytic_story],
|
|
132
148
|
"asset_type": self.asset_type.value,
|
|
@@ -141,34 +157,38 @@ class DetectionTags(BaseModel):
|
|
|
141
157
|
"mitre_attack_id": self.mitre_attack_id,
|
|
142
158
|
"mitre_attack_enrichments": self.mitre_attack_enrichments
|
|
143
159
|
}
|
|
144
|
-
|
|
145
|
-
|
|
160
|
+
|
|
146
161
|
@model_validator(mode="after")
|
|
147
|
-
def addAttackEnrichment(self, info:ValidationInfo):
|
|
162
|
+
def addAttackEnrichment(self, info: ValidationInfo):
|
|
163
|
+
if info.context is None:
|
|
164
|
+
raise ValueError("ValidationInfo.context unexpectedly null")
|
|
165
|
+
|
|
148
166
|
if len(self.mitre_attack_enrichments) > 0:
|
|
149
|
-
raise ValueError(
|
|
150
|
-
|
|
151
|
-
|
|
167
|
+
raise ValueError(
|
|
168
|
+
"Error, field 'mitre_attack_enrichment' should be empty and dynamically populated"
|
|
169
|
+
f" at runtime. Instead, this field contained: {self.mitre_attack_enrichments}"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
output_dto: Union[DirectorOutputDto, None] = info.context.get("output_dto", None)
|
|
152
173
|
if output_dto is None:
|
|
153
174
|
raise ValueError("Context not provided to detection.detection_tags model post validator")
|
|
154
|
-
|
|
175
|
+
|
|
155
176
|
if output_dto.attack_enrichment.use_enrichment is False:
|
|
156
177
|
return self
|
|
157
|
-
|
|
158
178
|
|
|
159
|
-
mitre_enrichments = []
|
|
160
|
-
missing_tactics = []
|
|
179
|
+
mitre_enrichments: list[MitreAttackEnrichment] = []
|
|
180
|
+
missing_tactics: list[str] = []
|
|
161
181
|
for mitre_attack_id in self.mitre_attack_id:
|
|
162
182
|
try:
|
|
163
183
|
mitre_enrichments.append(output_dto.attack_enrichment.getEnrichmentByMitreID(mitre_attack_id))
|
|
164
|
-
except Exception
|
|
165
|
-
missing_tactics.append(mitre_attack_id)
|
|
166
|
-
|
|
184
|
+
except Exception:
|
|
185
|
+
missing_tactics.append(mitre_attack_id)
|
|
186
|
+
|
|
167
187
|
if len(missing_tactics) > 0:
|
|
168
188
|
raise ValueError(f"Missing Mitre Attack IDs. {missing_tactics} not found.")
|
|
169
189
|
else:
|
|
170
190
|
self.mitre_attack_enrichments = mitre_enrichments
|
|
171
|
-
|
|
191
|
+
|
|
172
192
|
return self
|
|
173
193
|
|
|
174
194
|
'''
|
|
@@ -176,77 +196,83 @@ class DetectionTags(BaseModel):
|
|
|
176
196
|
@classmethod
|
|
177
197
|
def addAttackEnrichments(cls, v:list[MitreAttackEnrichment], info:ValidationInfo)->list[MitreAttackEnrichment]:
|
|
178
198
|
if len(v) > 0:
|
|
179
|
-
raise ValueError(
|
|
180
|
-
|
|
181
|
-
|
|
199
|
+
raise ValueError(
|
|
200
|
+
f"Error, field 'mitre_attack_enrichment' should be empty and dynamically populated"
|
|
201
|
+
f" at runtime. Instead, this field contained: {str(v)}"
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
182
205
|
output_dto:Union[DirectorOutputDto,None]= info.context.get("output_dto",None)
|
|
183
206
|
if output_dto is None:
|
|
184
207
|
raise ValueError("Context not provided to detection.detection_tags.mitre_attack_enrichments")
|
|
185
|
-
|
|
186
|
-
enrichments = []
|
|
187
|
-
|
|
188
208
|
|
|
209
|
+
enrichments = []
|
|
189
210
|
|
|
190
|
-
|
|
191
211
|
return enrichments
|
|
192
212
|
'''
|
|
193
213
|
|
|
194
|
-
@field_validator('analytic_story',mode="before")
|
|
214
|
+
@field_validator('analytic_story', mode="before")
|
|
195
215
|
@classmethod
|
|
196
|
-
def mapStoryNamesToStoryObjects(cls, v:list[str], info:ValidationInfo)->list[Story]:
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
216
|
+
def mapStoryNamesToStoryObjects(cls, v: list[str], info: ValidationInfo) -> list[Story]:
|
|
217
|
+
if info.context is None:
|
|
218
|
+
raise ValueError("ValidationInfo.context unexpectedly null")
|
|
219
|
+
|
|
220
|
+
return Story.mapNamesToSecurityContentObjects(v, info.context.get("output_dto", None))
|
|
221
|
+
|
|
222
|
+
def getAtomicGuidStringArray(self) -> List[str]:
|
|
200
223
|
return [str(atomic_guid.auto_generated_guid) for atomic_guid in self.atomic_guid]
|
|
201
|
-
|
|
202
224
|
|
|
203
|
-
@field_validator('atomic_guid',mode="before")
|
|
225
|
+
@field_validator('atomic_guid', mode="before")
|
|
204
226
|
@classmethod
|
|
205
|
-
def mapAtomicGuidsToAtomicTests(cls, v:List[UUID4], info:ValidationInfo)->List[AtomicTest]:
|
|
227
|
+
def mapAtomicGuidsToAtomicTests(cls, v: List[UUID4], info: ValidationInfo) -> List[AtomicTest]:
|
|
206
228
|
if len(v) == 0:
|
|
207
229
|
return []
|
|
208
|
-
|
|
209
|
-
|
|
230
|
+
|
|
231
|
+
if info.context is None:
|
|
232
|
+
raise ValueError("ValidationInfo.context unexpectedly null")
|
|
233
|
+
|
|
234
|
+
output_dto: Union[DirectorOutputDto, None] = info.context.get("output_dto", None)
|
|
210
235
|
if output_dto is None:
|
|
211
236
|
raise ValueError("Context not provided to detection.detection_tags.atomic_guid validator")
|
|
212
237
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
badly_formatted_guids:List[str] = []
|
|
238
|
+
all_tests: None | List[AtomicTest] = output_dto.atomic_tests
|
|
239
|
+
|
|
240
|
+
matched_tests: List[AtomicTest] = []
|
|
241
|
+
missing_tests: List[UUID4] = []
|
|
242
|
+
badly_formatted_guids: List[str] = []
|
|
219
243
|
for atomic_guid_str in v:
|
|
220
244
|
try:
|
|
221
|
-
#Ensure that this is a valid UUID
|
|
245
|
+
# Ensure that this is a valid UUID
|
|
222
246
|
atomic_guid = uuid.UUID(str(atomic_guid_str))
|
|
223
|
-
except Exception
|
|
224
|
-
#We will not try to load a test for this since it was invalid
|
|
247
|
+
except Exception:
|
|
248
|
+
# We will not try to load a test for this since it was invalid
|
|
225
249
|
badly_formatted_guids.append(str(atomic_guid_str))
|
|
226
250
|
continue
|
|
227
251
|
try:
|
|
228
|
-
matched_tests.append(AtomicTest.getAtomicByAtomicGuid(atomic_guid,all_tests))
|
|
229
|
-
except Exception
|
|
252
|
+
matched_tests.append(AtomicTest.getAtomicByAtomicGuid(atomic_guid, all_tests))
|
|
253
|
+
except Exception:
|
|
230
254
|
missing_tests.append(atomic_guid)
|
|
231
255
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
256
|
if len(missing_tests) > 0:
|
|
236
|
-
missing_tests_string =
|
|
237
|
-
|
|
238
|
-
|
|
257
|
+
missing_tests_string = (
|
|
258
|
+
f"\n\tWARNING: Failed to find [{len(missing_tests)}] Atomic Test(s) with the "
|
|
259
|
+
"following atomic_guids (called auto_generated_guid in the ART Repo)."
|
|
260
|
+
f"\n\tPlease review the output above for potential exception(s) when parsing the "
|
|
261
|
+
"Atomic Red Team Repo."
|
|
262
|
+
"\n\tVerify that these auto_generated_guid exist and try updating/pulling the "
|
|
263
|
+
f"repo again.: {[str(guid) for guid in missing_tests]}"
|
|
264
|
+
)
|
|
239
265
|
else:
|
|
240
266
|
missing_tests_string = ""
|
|
241
|
-
|
|
242
267
|
|
|
243
268
|
if len(badly_formatted_guids) > 0:
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
269
|
+
bad_guids_string = (
|
|
270
|
+
f"The following [{len(badly_formatted_guids)}] value(s) are not properly "
|
|
271
|
+
f"formatted UUIDs: {badly_formatted_guids}\n"
|
|
272
|
+
)
|
|
273
|
+
raise ValueError(f"{bad_guids_string}{missing_tests_string}")
|
|
274
|
+
|
|
248
275
|
elif len(missing_tests) > 0:
|
|
249
276
|
print(missing_tests_string)
|
|
250
277
|
|
|
251
278
|
return matched_tests + [AtomicTest.AtomicTestWhenTestIsMissing(test) for test in missing_tests]
|
|
252
|
-
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class ValidationFailed(Exception):
|
|
2
|
+
"""Indicates not an error in execution, but a validation failure"""
|
|
3
|
+
pass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class IntegrationTestingError(Exception):
|
|
7
|
+
"""Base exception class for integration testing"""
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ServerError(IntegrationTestingError):
|
|
12
|
+
"""An error encounterd during integration testing, as provided by the server (Splunk instance)"""
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ClientError(IntegrationTestingError):
|
|
17
|
+
"""An error encounterd during integration testing, on the client's side (locally)"""
|
|
18
|
+
pass
|
contentctl/objects/lookup.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
from pydantic import BaseModel, Field, ConfigDict
|
|
3
|
-
from typing import
|
|
3
|
+
from typing import List, Annotated
|
|
4
4
|
from enum import StrEnum
|
|
5
5
|
|
|
6
6
|
|
|
@@ -23,10 +23,10 @@ class MitreTactics(StrEnum):
|
|
|
23
23
|
|
|
24
24
|
class MitreAttackEnrichment(BaseModel):
|
|
25
25
|
ConfigDict(use_enum_values=True)
|
|
26
|
-
mitre_attack_id: Annotated[str, Field(pattern="^T\d{4}(.\d{3})?$")] = Field(...)
|
|
26
|
+
mitre_attack_id: Annotated[str, Field(pattern=r"^T\d{4}(.\d{3})?$")] = Field(...)
|
|
27
27
|
mitre_attack_technique: str = Field(...)
|
|
28
28
|
mitre_attack_tactics: List[MitreTactics] = Field(...)
|
|
29
29
|
mitre_attack_groups: List[str] = Field(...)
|
|
30
30
|
|
|
31
31
|
def __hash__(self) -> int:
|
|
32
|
-
return id(self)
|
|
32
|
+
return id(self)
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
from contentctl.objects.detection import Detection
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# TODO (PEX-434): implement deeper notable validation
|
|
7
|
+
class NotableEvent(BaseModel):
|
|
8
|
+
# The search name (e.g. "ESCU - Windows Modify Registry EnableLinkedConnections - Rule")
|
|
9
|
+
search_name: str
|
|
10
|
+
|
|
11
|
+
# The search ID that found that generated this risk event
|
|
12
|
+
orig_sid: str
|
|
13
|
+
|
|
14
|
+
class Config:
|
|
15
|
+
# Allowing fields that aren't explicitly defined to be passed since some of the risk event's
|
|
16
|
+
# fields vary depending on the SPL which generated them
|
|
17
|
+
extra = 'allow'
|
|
18
|
+
|
|
19
|
+
def validate_against_detection(self, detection: Detection) -> None:
|
|
20
|
+
raise NotImplementedError()
|
contentctl/objects/observable.py
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
|
-
from
|
|
2
|
-
from
|
|
3
|
-
|
|
4
|
-
from contentctl.objects.constants import *
|
|
5
|
-
|
|
1
|
+
from pydantic import BaseModel, field_validator
|
|
2
|
+
from contentctl.objects.constants import SES_OBSERVABLE_TYPE_MAPPING, SES_OBSERVABLE_ROLE_MAPPING
|
|
6
3
|
|
|
7
4
|
|
|
8
5
|
class Observable(BaseModel):
|
|
@@ -10,32 +7,29 @@ class Observable(BaseModel):
|
|
|
10
7
|
type: str
|
|
11
8
|
role: list[str]
|
|
12
9
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
@validator('name')
|
|
16
|
-
def check_name(cls, v, values):
|
|
10
|
+
@field_validator('name')
|
|
11
|
+
def check_name(cls, v: str):
|
|
17
12
|
if v == "":
|
|
18
13
|
raise ValueError("No name provided for observable")
|
|
19
14
|
return v
|
|
20
|
-
|
|
21
|
-
@
|
|
22
|
-
def check_type(cls, v
|
|
15
|
+
|
|
16
|
+
@field_validator('type')
|
|
17
|
+
def check_type(cls, v: str):
|
|
23
18
|
if v not in SES_OBSERVABLE_TYPE_MAPPING.keys():
|
|
24
|
-
raise ValueError(
|
|
19
|
+
raise ValueError(
|
|
20
|
+
f"Invalid type '{v}' provided for observable. Valid observable types are "
|
|
21
|
+
f"{SES_OBSERVABLE_TYPE_MAPPING.keys()}"
|
|
22
|
+
)
|
|
25
23
|
return v
|
|
26
24
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def check_roles_not_empty(cls, v, values):
|
|
25
|
+
@field_validator('role')
|
|
26
|
+
def check_roles(cls, v: list[str]):
|
|
30
27
|
if len(v) == 0:
|
|
31
|
-
raise ValueError("
|
|
28
|
+
raise ValueError("Error, at least 1 role must be listed for Observable.")
|
|
29
|
+
for role in v:
|
|
30
|
+
if role not in SES_OBSERVABLE_ROLE_MAPPING.keys():
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"Invalid role '{role}' provided for observable. Valid observable types are "
|
|
33
|
+
f"{SES_OBSERVABLE_ROLE_MAPPING.keys()}"
|
|
34
|
+
)
|
|
32
35
|
return v
|
|
33
|
-
|
|
34
|
-
@validator('role', each_item=True)
|
|
35
|
-
def check_roles(cls, v, values):
|
|
36
|
-
if v not in SES_OBSERVABLE_ROLE_MAPPING.keys():
|
|
37
|
-
raise ValueError(f"Invalid role '{v}' provided for observable. Valid observable types are {SES_OBSERVABLE_ROLE_MAPPING.keys()}")
|
|
38
|
-
return v
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
@@ -7,7 +7,7 @@ from contentctl.objects.risk_object import RiskObject
|
|
|
7
7
|
from contentctl.objects.threat_object import ThreatObject
|
|
8
8
|
|
|
9
9
|
|
|
10
|
-
# TODO (
|
|
10
|
+
# TODO (#231): add logic which reports concretely that integration testing failed (or would fail)
|
|
11
11
|
# as a result of a missing victim observable
|
|
12
12
|
class RiskAnalysisAction(BaseModel):
|
|
13
13
|
"""Representation of a risk analysis action
|
|
@@ -77,7 +77,7 @@ class RiskAnalysisAction(BaseModel):
|
|
|
77
77
|
risk_objects: list[RiskObject] = []
|
|
78
78
|
threat_objects: list[ThreatObject] = []
|
|
79
79
|
|
|
80
|
-
# TODO (
|
|
80
|
+
# TODO (#231): add validation ensuring at least 1 risk objects
|
|
81
81
|
for entry in object_dicts:
|
|
82
82
|
if "risk_object_field" in entry:
|
|
83
83
|
risk_objects.append(RiskObject(
|