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
contentctl/objects/risk_event.py
CHANGED
|
@@ -1,51 +1,18 @@
|
|
|
1
1
|
import re
|
|
2
2
|
from functools import cached_property
|
|
3
3
|
|
|
4
|
-
from pydantic import
|
|
5
|
-
|
|
4
|
+
from pydantic import (
|
|
5
|
+
BaseModel,
|
|
6
|
+
ConfigDict,
|
|
7
|
+
Field,
|
|
8
|
+
PrivateAttr,
|
|
9
|
+
computed_field,
|
|
10
|
+
field_validator,
|
|
11
|
+
)
|
|
12
|
+
|
|
6
13
|
from contentctl.objects.detection import Detection
|
|
7
|
-
from contentctl.objects.
|
|
8
|
-
|
|
9
|
-
# TODO (#259): Map our observable types to more than user/system
|
|
10
|
-
# TODO (#247): centralize this mapping w/ usage of SES_OBSERVABLE_TYPE_MAPPING (see
|
|
11
|
-
# observable.py) and the ad hoc mapping made in detection_abstract.py (see the risk property func)
|
|
12
|
-
TYPE_MAP: dict[str, list[str]] = {
|
|
13
|
-
"system": [
|
|
14
|
-
"Hostname",
|
|
15
|
-
"IP Address",
|
|
16
|
-
"Endpoint"
|
|
17
|
-
],
|
|
18
|
-
"user": [
|
|
19
|
-
"User",
|
|
20
|
-
"User Name",
|
|
21
|
-
"Email Address",
|
|
22
|
-
"Email"
|
|
23
|
-
],
|
|
24
|
-
"hash_values": [],
|
|
25
|
-
"network_artifacts": [],
|
|
26
|
-
"host_artifacts": [],
|
|
27
|
-
"tools": [],
|
|
28
|
-
"other": [
|
|
29
|
-
"Process",
|
|
30
|
-
"URL String",
|
|
31
|
-
"Unknown",
|
|
32
|
-
"Process Name",
|
|
33
|
-
"MAC Address",
|
|
34
|
-
"File Name",
|
|
35
|
-
"File Hash",
|
|
36
|
-
"Resource UID",
|
|
37
|
-
"Uniform Resource Locator",
|
|
38
|
-
"File",
|
|
39
|
-
"Geo Location",
|
|
40
|
-
"Container",
|
|
41
|
-
"Registry Key",
|
|
42
|
-
"Registry Value",
|
|
43
|
-
"Other"
|
|
44
|
-
]
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
# Roles that should not generate risks
|
|
48
|
-
IGNORE_ROLES: list[str] = ["Attacker"]
|
|
14
|
+
from contentctl.objects.errors import ValidationFailed
|
|
15
|
+
from contentctl.objects.rba import RiskObject
|
|
49
16
|
|
|
50
17
|
|
|
51
18
|
class RiskEvent(BaseModel):
|
|
@@ -55,10 +22,12 @@ class RiskEvent(BaseModel):
|
|
|
55
22
|
search_name: str
|
|
56
23
|
|
|
57
24
|
# The subject of the risk event (e.g. a username, process name, system name, account ID, etc.)
|
|
58
|
-
|
|
25
|
+
# (not to be confused w/ the risk object from the detection)
|
|
26
|
+
es_risk_object: int | str = Field(alias="risk_object")
|
|
59
27
|
|
|
60
|
-
# The type of the risk object (e.g. user, system, or other)
|
|
61
|
-
|
|
28
|
+
# The type of the risk object from ES (e.g. user, system, or other) (not to be confused w/
|
|
29
|
+
# the risk object from the detection)
|
|
30
|
+
es_risk_object_type: str = Field(alias="risk_object_type")
|
|
62
31
|
|
|
63
32
|
# The level of risk associated w/ the risk event
|
|
64
33
|
risk_score: int
|
|
@@ -74,22 +43,19 @@ class RiskEvent(BaseModel):
|
|
|
74
43
|
|
|
75
44
|
# The MITRE ATT&CK IDs
|
|
76
45
|
annotations_mitre_attack: list[str] = Field(
|
|
77
|
-
alias="annotations.mitre_attack",
|
|
78
|
-
default=[]
|
|
46
|
+
alias="annotations.mitre_attack", default=[]
|
|
79
47
|
)
|
|
80
48
|
|
|
81
49
|
# Contributing events search query (we use this to derive the corresponding field from the
|
|
82
|
-
#
|
|
50
|
+
# detection's risk object definition)
|
|
83
51
|
contributing_events_search: str
|
|
84
52
|
|
|
85
|
-
# Private attribute caching the
|
|
86
|
-
|
|
53
|
+
# Private attribute caching the risk object this RiskEvent is mapped to
|
|
54
|
+
_matched_risk_object: RiskObject | None = PrivateAttr(default=None)
|
|
87
55
|
|
|
88
56
|
# Allowing fields that aren't explicitly defined to be passed since some of the risk event's
|
|
89
57
|
# fields vary depending on the SPL which generated them
|
|
90
|
-
model_config = ConfigDict(
|
|
91
|
-
extra="allow"
|
|
92
|
-
)
|
|
58
|
+
model_config = ConfigDict(extra="allow")
|
|
93
59
|
|
|
94
60
|
@field_validator("annotations_mitre_attack", "analyticstories", mode="before")
|
|
95
61
|
@classmethod
|
|
@@ -108,10 +74,12 @@ class RiskEvent(BaseModel):
|
|
|
108
74
|
def source_field_name(self) -> str:
|
|
109
75
|
"""
|
|
110
76
|
A cached derivation of the source field name the risk event corresponds to in the relevant
|
|
111
|
-
event(s). Useful for mapping back to
|
|
77
|
+
event(s). Useful for mapping back to a risk object in the detection.
|
|
112
78
|
"""
|
|
113
79
|
pattern = re.compile(
|
|
114
|
-
r"\| savedsearch \""
|
|
80
|
+
r"\| savedsearch \""
|
|
81
|
+
+ self.search_name
|
|
82
|
+
+ r"\" \| search (?P<field>[^=]+)=.+"
|
|
115
83
|
)
|
|
116
84
|
match = pattern.search(self.contributing_events_search)
|
|
117
85
|
if match is None:
|
|
@@ -128,13 +96,6 @@ class RiskEvent(BaseModel):
|
|
|
128
96
|
:param detection: the detection associated w/ this risk event
|
|
129
97
|
:raises: ValidationFailed
|
|
130
98
|
"""
|
|
131
|
-
# Check risk_score
|
|
132
|
-
if self.risk_score != detection.tags.risk_score:
|
|
133
|
-
raise ValidationFailed(
|
|
134
|
-
f"Risk score observed in risk event ({self.risk_score}) does not match risk score in "
|
|
135
|
-
f"detection ({detection.tags.risk_score})."
|
|
136
|
-
)
|
|
137
|
-
|
|
138
99
|
# Check analyticstories
|
|
139
100
|
self.validate_analyticstories(detection)
|
|
140
101
|
|
|
@@ -151,8 +112,15 @@ class RiskEvent(BaseModel):
|
|
|
151
112
|
# Check risk_message
|
|
152
113
|
self.validate_risk_message(detection)
|
|
153
114
|
|
|
154
|
-
#
|
|
155
|
-
|
|
115
|
+
# Ensure the rba object is defined
|
|
116
|
+
if detection.rba is None:
|
|
117
|
+
raise ValidationFailed(
|
|
118
|
+
f"Unexpected error: Detection '{detection.name}' has no RBA objects associated "
|
|
119
|
+
"with it; cannot validate."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# Check several conditions against the detection's risk objects
|
|
123
|
+
self.validate_risk_against_risk_objects(detection.rba.risk_objects)
|
|
156
124
|
|
|
157
125
|
def validate_mitre_ids(self, detection: Detection) -> None:
|
|
158
126
|
"""
|
|
@@ -160,7 +128,9 @@ class RiskEvent(BaseModel):
|
|
|
160
128
|
:param detection: the detection associated w/ this risk event
|
|
161
129
|
:raises: ValidationFailed
|
|
162
130
|
"""
|
|
163
|
-
if sorted(self.annotations_mitre_attack) != sorted(
|
|
131
|
+
if sorted(self.annotations_mitre_attack) != sorted(
|
|
132
|
+
detection.tags.mitre_attack_id
|
|
133
|
+
):
|
|
164
134
|
raise ValidationFailed(
|
|
165
135
|
f"MITRE ATT&CK IDs in risk event ({self.annotations_mitre_attack}) do not match those"
|
|
166
136
|
f" in detection ({detection.tags.mitre_attack_id})."
|
|
@@ -173,11 +143,13 @@ class RiskEvent(BaseModel):
|
|
|
173
143
|
:raises: ValidationFailed
|
|
174
144
|
"""
|
|
175
145
|
# Render the detection analytic_story to a list of strings before comparing
|
|
176
|
-
detection_analytic_story = [
|
|
146
|
+
detection_analytic_story = [
|
|
147
|
+
story.name for story in detection.tags.analytic_story
|
|
148
|
+
]
|
|
177
149
|
if sorted(self.analyticstories) != sorted(detection_analytic_story):
|
|
178
150
|
raise ValidationFailed(
|
|
179
151
|
f"Analytic stories in risk event ({self.analyticstories}) do not match those"
|
|
180
|
-
f" in detection ({detection.tags.analytic_story})."
|
|
152
|
+
f" in detection ({[x.name for x in detection.tags.analytic_story]})."
|
|
181
153
|
)
|
|
182
154
|
|
|
183
155
|
def validate_risk_message(self, detection: Detection) -> None:
|
|
@@ -186,10 +158,20 @@ class RiskEvent(BaseModel):
|
|
|
186
158
|
:param detection: the detection associated w/ this risk event
|
|
187
159
|
:raises: ValidationFailed
|
|
188
160
|
"""
|
|
161
|
+
# Ensure the rba object is defined
|
|
162
|
+
if detection.rba is None:
|
|
163
|
+
raise ValidationFailed(
|
|
164
|
+
f"Unexpected error: Detection '{detection.name}' has no RBA objects associated "
|
|
165
|
+
"with it; cannot validate."
|
|
166
|
+
)
|
|
167
|
+
|
|
189
168
|
# Extract the field replacement tokens ("$...$")
|
|
190
169
|
field_replacement_pattern = re.compile(r"\$\S+\$")
|
|
191
|
-
tokens = field_replacement_pattern.findall(detection.
|
|
170
|
+
tokens = field_replacement_pattern.findall(detection.rba.message)
|
|
192
171
|
|
|
172
|
+
# TODO (#346): could expand this to get the field values from the raw events and check
|
|
173
|
+
# to see that allexpected strings ARE in the risk message (as opposed to checking only
|
|
174
|
+
# that unexpected strings aren't)
|
|
193
175
|
# Check for the presence of each token in the message from the risk event
|
|
194
176
|
for token in tokens:
|
|
195
177
|
if token in self.risk_message:
|
|
@@ -203,16 +185,12 @@ class RiskEvent(BaseModel):
|
|
|
203
185
|
# placeholder
|
|
204
186
|
tmp_placeholder = "PLACEHOLDERPATTERNFORESCAPING"
|
|
205
187
|
escaped_source_message_with_placeholder: str = re.escape(
|
|
206
|
-
field_replacement_pattern.sub(
|
|
207
|
-
tmp_placeholder,
|
|
208
|
-
detection.tags.message
|
|
209
|
-
)
|
|
188
|
+
field_replacement_pattern.sub(tmp_placeholder, detection.rba.message)
|
|
210
189
|
)
|
|
211
190
|
placeholder_replacement_pattern = re.compile(tmp_placeholder)
|
|
212
191
|
final_risk_message_pattern = re.compile(
|
|
213
192
|
placeholder_replacement_pattern.sub(
|
|
214
|
-
r"[\\s\\S]*\\S[\\s\\S]*",
|
|
215
|
-
escaped_source_message_with_placeholder
|
|
193
|
+
r"[\\s\\S]*\\S[\\s\\S]*", escaped_source_message_with_placeholder
|
|
216
194
|
)
|
|
217
195
|
)
|
|
218
196
|
|
|
@@ -220,115 +198,87 @@ class RiskEvent(BaseModel):
|
|
|
220
198
|
if final_risk_message_pattern.match(self.risk_message) is None:
|
|
221
199
|
raise ValidationFailed(
|
|
222
200
|
"Risk message in event does not match the pattern set by the detection. Message in "
|
|
223
|
-
f
|
|
224
|
-
f"
|
|
201
|
+
f'risk event: "{self.risk_message}". Message in detection: '
|
|
202
|
+
f'"{detection.rba.message}".'
|
|
225
203
|
)
|
|
226
204
|
|
|
227
|
-
def
|
|
205
|
+
def validate_risk_against_risk_objects(self, risk_objects: set[RiskObject]) -> None:
|
|
228
206
|
"""
|
|
229
|
-
Given the
|
|
230
|
-
|
|
231
|
-
:param
|
|
207
|
+
Given the risk objects from the associated detection, validate the risk event against those
|
|
208
|
+
risk objects
|
|
209
|
+
:param risk_objects: the risk objects from the detection
|
|
232
210
|
:raises: ValidationFailed
|
|
233
211
|
"""
|
|
234
|
-
# Get the matched
|
|
235
|
-
# risk is missing values associated w/
|
|
236
|
-
|
|
212
|
+
# Get the matched risk object; will raise validation errors if no match can be made or if
|
|
213
|
+
# risk is missing values associated w/ risk objects
|
|
214
|
+
matched_risk_object = self.get_matched_risk_object(risk_objects)
|
|
237
215
|
|
|
238
|
-
# The risk object type should match our mapping of
|
|
239
|
-
|
|
240
|
-
if self.
|
|
216
|
+
# The risk object type from the risk event should match our mapping of internal risk object
|
|
217
|
+
# types
|
|
218
|
+
if self.es_risk_object_type != matched_risk_object.type.value:
|
|
241
219
|
raise ValidationFailed(
|
|
242
|
-
f"The risk object type ({self.
|
|
243
|
-
|
|
244
|
-
f"
|
|
245
|
-
f"source_field_name={self.source_field_name}), "
|
|
246
|
-
f"
|
|
247
|
-
f"
|
|
220
|
+
f"The risk object type from the risk event ({self.es_risk_object_type}) does not match"
|
|
221
|
+
" the expected type based on the matched risk object "
|
|
222
|
+
f"({matched_risk_object.type.value}): risk event=(object={self.es_risk_object}, "
|
|
223
|
+
f"type={self.es_risk_object_type}, source_field_name={self.source_field_name}), "
|
|
224
|
+
f"risk object=(name={matched_risk_object.field}, "
|
|
225
|
+
f"type={matched_risk_object.type.value})"
|
|
248
226
|
)
|
|
249
227
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
:returns: a string (the risk object type)
|
|
257
|
-
:raises ValueError: if the observable type has not yet been mapped to a risk object type
|
|
258
|
-
"""
|
|
259
|
-
# Iterate over the map and search the lists for a match
|
|
260
|
-
for risk_type in TYPE_MAP:
|
|
261
|
-
if observable_type in TYPE_MAP[risk_type]:
|
|
262
|
-
return risk_type
|
|
263
|
-
|
|
264
|
-
raise ValueError(
|
|
265
|
-
f"Observable type {observable_type} does not have a mapping to a risk type in TYPE_MAP"
|
|
266
|
-
)
|
|
228
|
+
# Check risk_score
|
|
229
|
+
if self.risk_score != matched_risk_object.score:
|
|
230
|
+
raise ValidationFailed(
|
|
231
|
+
f"Risk score observed in risk event ({self.risk_score}) does not match risk score in "
|
|
232
|
+
f"matched risk object from detection ({matched_risk_object.score})."
|
|
233
|
+
)
|
|
267
234
|
|
|
268
|
-
|
|
269
|
-
def ignore_observable(observable: Observable) -> bool:
|
|
270
|
-
"""
|
|
271
|
-
Given an observable, determine based on its roles if it should be ignored in risk/observable
|
|
272
|
-
matching (e.g. Attacker role observables should not generate risk events)
|
|
273
|
-
:param observable: the Observable object we are checking the roles of
|
|
274
|
-
:returns: a bool indicating whether this observable should be ignored or not
|
|
275
|
-
"""
|
|
276
|
-
ignore = False
|
|
277
|
-
for role in observable.role:
|
|
278
|
-
if role in IGNORE_ROLES:
|
|
279
|
-
ignore = True
|
|
280
|
-
break
|
|
281
|
-
return ignore
|
|
282
|
-
|
|
283
|
-
def get_matched_observable(self, observables: list[Observable]) -> Observable:
|
|
235
|
+
def get_matched_risk_object(self, risk_objects: set[RiskObject]) -> RiskObject:
|
|
284
236
|
"""
|
|
285
|
-
Given a
|
|
286
|
-
:param
|
|
287
|
-
:returns: the matched
|
|
237
|
+
Given a set of risk objects, return the one this risk event matches
|
|
238
|
+
:param risk_objects: the list of risk objects we are checking against
|
|
239
|
+
:returns: the matched risk object
|
|
288
240
|
:raises ValidationFailed: if a match could not be made or if an expected field (based on
|
|
289
|
-
one of the
|
|
241
|
+
one of the risk objects) could not be found in the risk event
|
|
290
242
|
"""
|
|
291
243
|
# Return the cached match if already found
|
|
292
|
-
if self.
|
|
293
|
-
return self.
|
|
244
|
+
if self._matched_risk_object is not None:
|
|
245
|
+
return self._matched_risk_object
|
|
294
246
|
|
|
295
|
-
|
|
247
|
+
matched_risk_object: RiskObject | None = None
|
|
296
248
|
|
|
297
249
|
# Iterate over the obervables and check for a match
|
|
298
|
-
for
|
|
250
|
+
for risk_object in risk_objects:
|
|
299
251
|
# TODO (#252): Refactor and re-enable per-field validation of risk events
|
|
300
|
-
# Each the field name used in each
|
|
301
|
-
# if not hasattr(self,
|
|
252
|
+
# Each the field name used in each risk object shoud be present in the risk event
|
|
253
|
+
# if not hasattr(self, risk_object.field):
|
|
302
254
|
# raise ValidationFailed(
|
|
303
|
-
# f"
|
|
255
|
+
# f"Risk object field \"{risk_object.field}\" not found in risk event."
|
|
304
256
|
# )
|
|
305
257
|
|
|
306
|
-
# Try to match the risk_object against a specific
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
258
|
+
# Try to match the risk_object against a specific risk object
|
|
259
|
+
if self.source_field_name == risk_object.field:
|
|
260
|
+
# TODO (#347): enforce that field names are not repeated across risk objects as
|
|
261
|
+
# part of build/validate
|
|
262
|
+
if matched_risk_object is not None:
|
|
310
263
|
raise ValueError(
|
|
311
|
-
"Unexpected conditon: we don't expect
|
|
312
|
-
"
|
|
264
|
+
"Unexpected conditon: we don't expect multiple risk objects to use the "
|
|
265
|
+
"same field name, so we should not be able match the risk event to "
|
|
266
|
+
"multiple risk objects."
|
|
313
267
|
)
|
|
314
268
|
|
|
315
|
-
#
|
|
316
|
-
|
|
317
|
-
raise ValidationFailed(
|
|
318
|
-
"Risk event matched an observable with an invalid role: "
|
|
319
|
-
f"(name={observable.name}, type={observable.type}, role={observable.role})")
|
|
320
|
-
# NOTE: we explicitly do not break early as we want to check each observable
|
|
321
|
-
matched_observable = observable
|
|
269
|
+
# NOTE: we explicitly do not break early as we want to check each risk object
|
|
270
|
+
matched_risk_object = risk_object
|
|
322
271
|
|
|
323
|
-
# Ensure we were able to match the risk event to a specific
|
|
324
|
-
if
|
|
272
|
+
# Ensure we were able to match the risk event to a specific risk object
|
|
273
|
+
if matched_risk_object is None:
|
|
325
274
|
raise ValidationFailed(
|
|
326
|
-
f"Unable to match risk event (object={self.
|
|
327
|
-
f"{self.
|
|
328
|
-
"
|
|
329
|
-
"detection, as well as the risk event build process in contentctl."
|
|
275
|
+
f"Unable to match risk event (object={self.es_risk_object}, type="
|
|
276
|
+
f"{self.es_risk_object_type}, source_field_name={self.source_field_name}) to a "
|
|
277
|
+
"risk object in the detection; please check for errors in the risk object types for this "
|
|
278
|
+
"detection, as well as the risk event build process in contentctl (e.g. threat "
|
|
279
|
+
"objects aren't being converted to risk objects somehow)."
|
|
330
280
|
)
|
|
331
281
|
|
|
332
|
-
# Cache and return the matched
|
|
333
|
-
self.
|
|
334
|
-
return self.
|
|
282
|
+
# Cache and return the matched risk object
|
|
283
|
+
self._matched_risk_object = matched_risk_object
|
|
284
|
+
return self._matched_risk_object
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
from pathlib import Path
|
|
3
2
|
from typing import Any, ClassVar
|
|
4
3
|
import re
|
|
@@ -17,6 +16,7 @@ class SavedsearchesConf(BaseModel):
|
|
|
17
16
|
NOTE: At present, this model only parses the detections themselves from the .conf; thing like
|
|
18
17
|
baselines or response tasks are left alone currently
|
|
19
18
|
"""
|
|
19
|
+
|
|
20
20
|
# The path to the conf file
|
|
21
21
|
path: Path = Field(...)
|
|
22
22
|
|
|
@@ -112,8 +112,7 @@ class SavedsearchesConf(BaseModel):
|
|
|
112
112
|
|
|
113
113
|
# Build the stanza model from the accumulated lines and adjust the state to end this section
|
|
114
114
|
self.detection_stanzas[self._current_section_name] = DetectionStanza(
|
|
115
|
-
name=self._current_section_name,
|
|
116
|
-
lines=self._current_section_lines
|
|
115
|
+
name=self._current_section_name, lines=self._current_section_lines
|
|
117
116
|
)
|
|
118
117
|
self._in_section = False
|
|
119
118
|
|
|
@@ -170,7 +169,9 @@ class SavedsearchesConf(BaseModel):
|
|
|
170
169
|
self._in_detections = True
|
|
171
170
|
|
|
172
171
|
@staticmethod
|
|
173
|
-
def init_from_package(
|
|
172
|
+
def init_from_package(
|
|
173
|
+
package_path: Path, app_name: str, appid: str
|
|
174
|
+
) -> "SavedsearchesConf":
|
|
174
175
|
"""
|
|
175
176
|
Alternate constructor which can take an app package, and extract the savedsearches.conf from
|
|
176
177
|
a temporary file.
|
|
@@ -188,9 +189,10 @@ class SavedsearchesConf(BaseModel):
|
|
|
188
189
|
# Open the tar/gzip archive
|
|
189
190
|
with tarfile.open(package_path) as package:
|
|
190
191
|
# Extract the savedsearches.conf and use it to init the model
|
|
191
|
-
package_conf_path = SavedsearchesConf.PACKAGE_CONF_PATH_FMT_STR.format(
|
|
192
|
+
package_conf_path = SavedsearchesConf.PACKAGE_CONF_PATH_FMT_STR.format(
|
|
193
|
+
appid=appid
|
|
194
|
+
)
|
|
192
195
|
package.extract(package_conf_path, path=tmpdir)
|
|
193
196
|
return SavedsearchesConf(
|
|
194
|
-
path=Path(tmpdir, package_conf_path),
|
|
195
|
-
app_label=app_name
|
|
197
|
+
path=Path(tmpdir, package_conf_path), app_label=app_name
|
|
196
198
|
)
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from contentctl.objects.abstract_security_content_objects.security_content_object_abstract import
|
|
2
|
+
from contentctl.objects.abstract_security_content_objects.security_content_object_abstract import (
|
|
3
|
+
SecurityContentObject_Abstract,
|
|
4
|
+
)
|
|
5
|
+
|
|
3
6
|
|
|
4
7
|
class SecurityContentObject(SecurityContentObject_Abstract):
|
|
5
|
-
pass
|
|
8
|
+
pass
|
contentctl/objects/story.py
CHANGED
|
@@ -1,99 +1,106 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from contentctl.objects.story_tags import StoryTags
|
|
4
|
-
from pydantic import Field, model_serializer,computed_field, model_validator
|
|
2
|
+
|
|
5
3
|
import re
|
|
4
|
+
from typing import TYPE_CHECKING, List, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import Field, computed_field, model_serializer, model_validator
|
|
7
|
+
|
|
8
|
+
from contentctl.objects.story_tags import StoryTags
|
|
9
|
+
|
|
6
10
|
if TYPE_CHECKING:
|
|
7
|
-
from contentctl.objects.detection import Detection
|
|
8
|
-
from contentctl.objects.investigation import Investigation
|
|
9
11
|
from contentctl.objects.baseline import Baseline
|
|
10
|
-
from contentctl.objects.data_source import DataSource
|
|
11
12
|
from contentctl.objects.config import CustomApp
|
|
13
|
+
from contentctl.objects.data_source import DataSource
|
|
14
|
+
from contentctl.objects.detection import Detection
|
|
15
|
+
from contentctl.objects.investigation import Investigation
|
|
12
16
|
|
|
17
|
+
from contentctl.objects.enums import DetectionStatus
|
|
13
18
|
from contentctl.objects.security_content_object import SecurityContentObject
|
|
14
19
|
|
|
20
|
+
|
|
15
21
|
class Story(SecurityContentObject):
|
|
16
22
|
narrative: str = Field(...)
|
|
17
23
|
tags: StoryTags = Field(...)
|
|
18
|
-
|
|
24
|
+
status: Literal[DetectionStatus.production, DetectionStatus.deprecated]
|
|
19
25
|
# These are updated when detection and investigation objects are created.
|
|
20
26
|
# Specifically in the model_post_init functions
|
|
21
|
-
detections:List[Detection] = []
|
|
27
|
+
detections: List[Detection] = []
|
|
22
28
|
investigations: List[Investigation] = []
|
|
23
29
|
baselines: List[Baseline] = []
|
|
24
|
-
|
|
25
|
-
|
|
30
|
+
|
|
26
31
|
@computed_field
|
|
27
32
|
@property
|
|
28
|
-
def data_sources(self)-> list[DataSource]:
|
|
33
|
+
def data_sources(self) -> list[DataSource]:
|
|
29
34
|
# Only add a data_source if it does not already exist in the story
|
|
30
|
-
data_source_objects:set[DataSource] = set()
|
|
35
|
+
data_source_objects: set[DataSource] = set()
|
|
31
36
|
for detection in self.detections:
|
|
32
37
|
data_source_objects.update(set(detection.data_source_objects))
|
|
33
|
-
|
|
38
|
+
|
|
34
39
|
return sorted(list(data_source_objects))
|
|
35
40
|
|
|
41
|
+
def storyAndInvestigationNamesWithApp(self, app: CustomApp) -> List[str]:
|
|
42
|
+
return [
|
|
43
|
+
detection.get_conf_stanza_name(app) for detection in self.detections
|
|
44
|
+
] + [
|
|
45
|
+
investigation.get_response_task_name(app)
|
|
46
|
+
for investigation in self.investigations
|
|
47
|
+
]
|
|
36
48
|
|
|
37
|
-
def storyAndInvestigationNamesWithApp(self, app:CustomApp)->List[str]:
|
|
38
|
-
return [detection.get_conf_stanza_name(app) for detection in self.detections] + \
|
|
39
|
-
[investigation.get_response_task_name(app) for investigation in self.investigations]
|
|
40
|
-
|
|
41
49
|
@model_serializer
|
|
42
50
|
def serialize_model(self):
|
|
43
|
-
#Call serializer for parent
|
|
51
|
+
# Call serializer for parent
|
|
44
52
|
super_fields = super().serialize_model()
|
|
45
|
-
|
|
46
|
-
#All fields custom to this model
|
|
47
|
-
model= {
|
|
53
|
+
|
|
54
|
+
# All fields custom to this model
|
|
55
|
+
model = {
|
|
48
56
|
"narrative": self.narrative,
|
|
49
57
|
"tags": self.tags.model_dump(),
|
|
50
58
|
"detection_names": self.detection_names,
|
|
51
59
|
"investigation_names": self.investigation_names,
|
|
52
60
|
"baseline_names": self.baseline_names,
|
|
53
61
|
"author_company": self.author_company,
|
|
54
|
-
"author_name":self.author_name
|
|
62
|
+
"author_name": self.author_name,
|
|
55
63
|
}
|
|
56
64
|
detections = []
|
|
57
65
|
for detection in self.detections:
|
|
58
66
|
new_detection = {
|
|
59
|
-
"name":detection.name,
|
|
60
|
-
"source":detection.source,
|
|
61
|
-
"type":detection.type
|
|
67
|
+
"name": detection.name,
|
|
68
|
+
"source": detection.source,
|
|
69
|
+
"type": detection.type,
|
|
62
70
|
}
|
|
63
71
|
if self.tags.mitre_attack_enrichments is not None:
|
|
64
|
-
new_detection[
|
|
72
|
+
new_detection["tags"] = {
|
|
73
|
+
"mitre_attack_enrichments": [
|
|
74
|
+
{"mitre_attack_technique": enrichment.mitre_attack_technique}
|
|
75
|
+
for enrichment in detection.tags.mitre_attack_enrichments
|
|
76
|
+
]
|
|
77
|
+
}
|
|
65
78
|
detections.append(new_detection)
|
|
66
79
|
|
|
67
|
-
model[
|
|
68
|
-
#Combine fields from this model with fields from parent
|
|
80
|
+
model["detections"] = detections
|
|
81
|
+
# Combine fields from this model with fields from parent
|
|
69
82
|
super_fields.update(model)
|
|
70
|
-
|
|
71
|
-
#return the model
|
|
83
|
+
|
|
84
|
+
# return the model
|
|
72
85
|
return super_fields
|
|
73
86
|
|
|
74
87
|
@model_validator(mode="after")
|
|
75
88
|
def setTagsFields(self):
|
|
76
|
-
|
|
77
89
|
enrichments = []
|
|
78
90
|
for detection in self.detections:
|
|
79
91
|
enrichments.extend(detection.tags.mitre_attack_enrichments)
|
|
80
92
|
self.tags.mitre_attack_enrichments = list(set(enrichments))
|
|
81
93
|
|
|
82
|
-
|
|
83
94
|
tactics = []
|
|
84
95
|
for enrichment in self.tags.mitre_attack_enrichments:
|
|
85
96
|
tactics.extend(enrichment.mitre_attack_tactics)
|
|
86
97
|
self.tags.mitre_attack_tactics = set(tactics)
|
|
87
98
|
|
|
88
|
-
|
|
89
|
-
|
|
90
99
|
datamodels = []
|
|
91
100
|
for detection in self.detections:
|
|
92
101
|
datamodels.extend(detection.datamodel)
|
|
93
102
|
self.tags.datamodels = set(datamodels)
|
|
94
103
|
|
|
95
|
-
|
|
96
|
-
|
|
97
104
|
kill_chain_phases = []
|
|
98
105
|
for detection in self.detections:
|
|
99
106
|
kill_chain_phases.extend(detection.tags.kill_chain_phases)
|
|
@@ -101,42 +108,40 @@ class Story(SecurityContentObject):
|
|
|
101
108
|
|
|
102
109
|
return self
|
|
103
110
|
|
|
104
|
-
|
|
105
111
|
@computed_field
|
|
106
112
|
@property
|
|
107
|
-
def author_name(self)->str:
|
|
108
|
-
match_author = re.search(r
|
|
113
|
+
def author_name(self) -> str:
|
|
114
|
+
match_author = re.search(r"^([^,]+)", self.author)
|
|
109
115
|
if match_author is None:
|
|
110
|
-
return
|
|
116
|
+
return "no"
|
|
111
117
|
else:
|
|
112
118
|
return match_author.group(1)
|
|
113
119
|
|
|
114
120
|
@computed_field
|
|
115
121
|
@property
|
|
116
|
-
def author_company(self)->str:
|
|
117
|
-
match_company = re.search(r
|
|
122
|
+
def author_company(self) -> str:
|
|
123
|
+
match_company = re.search(r",\s?(.*)$", self.author)
|
|
118
124
|
if match_company is None:
|
|
119
|
-
return
|
|
125
|
+
return "no"
|
|
120
126
|
else:
|
|
121
127
|
return match_company.group(1)
|
|
122
128
|
|
|
123
129
|
@computed_field
|
|
124
130
|
@property
|
|
125
|
-
def author_email(self)->str:
|
|
131
|
+
def author_email(self) -> str:
|
|
126
132
|
return "-"
|
|
127
133
|
|
|
128
134
|
@computed_field
|
|
129
135
|
@property
|
|
130
|
-
def detection_names(self)->List[str]:
|
|
136
|
+
def detection_names(self) -> List[str]:
|
|
131
137
|
return [detection.name for detection in self.detections]
|
|
132
|
-
|
|
138
|
+
|
|
133
139
|
@computed_field
|
|
134
140
|
@property
|
|
135
|
-
def investigation_names(self)->List[str]:
|
|
141
|
+
def investigation_names(self) -> List[str]:
|
|
136
142
|
return [investigation.name for investigation in self.investigations]
|
|
137
143
|
|
|
138
144
|
@computed_field
|
|
139
145
|
@property
|
|
140
|
-
def baseline_names(self)->List[str]:
|
|
146
|
+
def baseline_names(self) -> List[str]:
|
|
141
147
|
return [baseline.name for baseline in self.baselines]
|
|
142
|
-
|