contentctl 5.0.0a0__py3-none-any.whl → 5.0.0a3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- contentctl/__init__.py +1 -1
- contentctl/actions/build.py +88 -55
- contentctl/actions/deploy_acs.py +29 -24
- contentctl/actions/detection_testing/DetectionTestingManager.py +66 -41
- contentctl/actions/detection_testing/GitService.py +134 -76
- contentctl/actions/detection_testing/generate_detection_coverage_badge.py +48 -30
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +163 -124
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +45 -32
- contentctl/actions/detection_testing/progress_bar.py +3 -0
- contentctl/actions/detection_testing/views/DetectionTestingView.py +15 -18
- contentctl/actions/detection_testing/views/DetectionTestingViewCLI.py +1 -5
- contentctl/actions/detection_testing/views/DetectionTestingViewFile.py +2 -2
- contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py +1 -4
- contentctl/actions/doc_gen.py +9 -5
- contentctl/actions/initialize.py +45 -33
- contentctl/actions/inspect.py +118 -61
- contentctl/actions/new_content.py +78 -50
- contentctl/actions/release_notes.py +276 -146
- contentctl/actions/reporting.py +23 -19
- contentctl/actions/test.py +31 -25
- contentctl/actions/validate.py +54 -34
- contentctl/api.py +54 -45
- contentctl/contentctl.py +12 -13
- contentctl/enrichments/attack_enrichment.py +112 -72
- contentctl/enrichments/cve_enrichment.py +34 -28
- contentctl/enrichments/splunk_app_enrichment.py +38 -36
- contentctl/helper/link_validator.py +101 -78
- contentctl/helper/splunk_app.py +69 -41
- contentctl/helper/utils.py +58 -39
- contentctl/input/director.py +69 -37
- contentctl/input/new_content_questions.py +26 -34
- contentctl/input/yml_reader.py +22 -17
- contentctl/objects/abstract_security_content_objects/detection_abstract.py +250 -314
- contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +58 -36
- contentctl/objects/alert_action.py +8 -8
- contentctl/objects/annotated_types.py +1 -1
- contentctl/objects/atomic.py +64 -54
- contentctl/objects/base_test.py +2 -1
- contentctl/objects/base_test_result.py +16 -8
- contentctl/objects/baseline.py +41 -30
- contentctl/objects/baseline_tags.py +29 -22
- contentctl/objects/config.py +772 -560
- contentctl/objects/constants.py +29 -58
- contentctl/objects/correlation_search.py +75 -55
- contentctl/objects/dashboard.py +55 -41
- contentctl/objects/data_source.py +13 -13
- contentctl/objects/deployment.py +44 -37
- contentctl/objects/deployment_email.py +1 -1
- contentctl/objects/deployment_notable.py +2 -1
- contentctl/objects/deployment_phantom.py +5 -5
- contentctl/objects/deployment_rba.py +1 -1
- contentctl/objects/deployment_scheduling.py +1 -1
- contentctl/objects/deployment_slack.py +1 -1
- contentctl/objects/detection.py +5 -2
- contentctl/objects/detection_metadata.py +1 -0
- contentctl/objects/detection_stanza.py +7 -2
- contentctl/objects/detection_tags.py +54 -64
- contentctl/objects/drilldown.py +66 -35
- contentctl/objects/enums.py +61 -43
- contentctl/objects/errors.py +16 -24
- contentctl/objects/integration_test.py +3 -3
- contentctl/objects/integration_test_result.py +1 -0
- contentctl/objects/investigation.py +41 -26
- contentctl/objects/investigation_tags.py +29 -17
- contentctl/objects/lookup.py +234 -113
- contentctl/objects/macro.py +55 -38
- contentctl/objects/manual_test.py +3 -3
- contentctl/objects/manual_test_result.py +1 -0
- contentctl/objects/mitre_attack_enrichment.py +17 -16
- contentctl/objects/notable_action.py +2 -1
- contentctl/objects/notable_event.py +1 -3
- contentctl/objects/playbook.py +37 -35
- contentctl/objects/playbook_tags.py +22 -16
- contentctl/objects/rba.py +14 -8
- contentctl/objects/risk_analysis_action.py +15 -11
- contentctl/objects/risk_event.py +27 -20
- contentctl/objects/risk_object.py +1 -0
- contentctl/objects/savedsearches_conf.py +9 -7
- contentctl/objects/security_content_object.py +5 -2
- contentctl/objects/story.py +45 -44
- contentctl/objects/story_tags.py +56 -44
- contentctl/objects/test_group.py +5 -2
- contentctl/objects/threat_object.py +1 -0
- contentctl/objects/throttling.py +27 -18
- contentctl/objects/unit_test.py +3 -4
- contentctl/objects/unit_test_baseline.py +4 -5
- contentctl/objects/unit_test_result.py +6 -6
- contentctl/output/api_json_output.py +22 -22
- contentctl/output/attack_nav_output.py +21 -21
- contentctl/output/attack_nav_writer.py +29 -37
- contentctl/output/conf_output.py +230 -174
- contentctl/output/data_source_writer.py +38 -25
- contentctl/output/doc_md_output.py +53 -27
- contentctl/output/jinja_writer.py +19 -15
- contentctl/output/json_writer.py +20 -8
- contentctl/output/svg_output.py +56 -38
- contentctl/output/templates/savedsearches_detections.j2 +1 -1
- contentctl/output/templates/transforms.j2 +2 -2
- contentctl/output/yml_writer.py +18 -24
- {contentctl-5.0.0a0.dist-info → contentctl-5.0.0a3.dist-info}/METADATA +1 -1
- contentctl-5.0.0a3.dist-info/RECORD +168 -0
- contentctl/actions/initialize_old.py +0 -245
- contentctl/objects/observable.py +0 -39
- contentctl-5.0.0a0.dist-info/RECORD +0 -170
- {contentctl-5.0.0a0.dist-info → contentctl-5.0.0a3.dist-info}/LICENSE.md +0 -0
- {contentctl-5.0.0a0.dist-info → contentctl-5.0.0a3.dist-info}/WHEEL +0 -0
- {contentctl-5.0.0a0.dist-info → contentctl-5.0.0a3.dist-info}/entry_points.txt +0 -0
|
@@ -1,66 +1,65 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import re
|
|
2
|
+
|
|
4
3
|
import pathlib
|
|
4
|
+
import re
|
|
5
5
|
from enum import StrEnum
|
|
6
|
+
from typing import TYPE_CHECKING, Annotated, Any, List, Optional, Union
|
|
6
7
|
|
|
7
8
|
from pydantic import (
|
|
8
|
-
field_validator,
|
|
9
|
-
model_validator,
|
|
10
|
-
ValidationInfo,
|
|
11
9
|
Field,
|
|
10
|
+
FilePath,
|
|
11
|
+
ValidationInfo,
|
|
12
12
|
computed_field,
|
|
13
|
+
field_validator,
|
|
13
14
|
model_serializer,
|
|
14
|
-
|
|
15
|
-
FilePath
|
|
15
|
+
model_validator,
|
|
16
16
|
)
|
|
17
17
|
|
|
18
|
+
from contentctl.objects.lookup import FileBackedLookup, KVStoreLookup, Lookup
|
|
18
19
|
from contentctl.objects.macro import Macro
|
|
19
|
-
|
|
20
|
+
|
|
20
21
|
if TYPE_CHECKING:
|
|
21
22
|
from contentctl.input.director import DirectorOutputDto
|
|
22
23
|
from contentctl.objects.baseline import Baseline
|
|
23
24
|
from contentctl.objects.config import CustomApp
|
|
24
|
-
|
|
25
|
-
from contentctl.objects.security_content_object import SecurityContentObject
|
|
26
|
-
from contentctl.objects.enums import AnalyticsType
|
|
27
|
-
from contentctl.objects.enums import DataModel
|
|
28
|
-
from contentctl.objects.enums import DetectionStatus
|
|
29
|
-
from contentctl.objects.enums import NistCategory
|
|
30
25
|
|
|
31
|
-
|
|
32
|
-
from contentctl.objects.deployment import Deployment
|
|
33
|
-
from contentctl.objects.unit_test import UnitTest
|
|
34
|
-
from contentctl.objects.manual_test import ManualTest
|
|
35
|
-
from contentctl.objects.test_group import TestGroup
|
|
36
|
-
from contentctl.objects.integration_test import IntegrationTest
|
|
37
|
-
from contentctl.objects.data_source import DataSource
|
|
38
|
-
|
|
39
|
-
from contentctl.objects.rba import RBAObject
|
|
26
|
+
import datetime
|
|
40
27
|
|
|
41
|
-
from contentctl.objects.base_test_result import TestResultStatus
|
|
42
|
-
from contentctl.objects.drilldown import Drilldown, DRILLDOWN_SEARCH_PLACEHOLDER
|
|
43
|
-
from contentctl.objects.enums import ProvidingTechnology
|
|
44
28
|
from contentctl.enrichments.cve_enrichment import CveEnrichmentObj
|
|
45
|
-
import
|
|
29
|
+
from contentctl.objects.base_test_result import TestResultStatus
|
|
46
30
|
from contentctl.objects.constants import (
|
|
31
|
+
CONTENTCTL_DETECTION_STANZA_NAME_FORMAT_TEMPLATE,
|
|
32
|
+
CONTENTCTL_MAX_SEARCH_NAME_LENGTH,
|
|
47
33
|
ES_MAX_STANZA_LENGTH,
|
|
48
34
|
ES_SEARCH_STANZA_NAME_FORMAT_AFTER_CLONING_IN_PRODUCT_TEMPLATE,
|
|
49
|
-
CONTENTCTL_MAX_SEARCH_NAME_LENGTH,
|
|
50
|
-
CONTENTCTL_DETECTION_STANZA_NAME_FORMAT_TEMPLATE
|
|
51
35
|
)
|
|
36
|
+
from contentctl.objects.data_source import DataSource
|
|
37
|
+
from contentctl.objects.deployment import Deployment
|
|
38
|
+
from contentctl.objects.detection_tags import DetectionTags
|
|
39
|
+
from contentctl.objects.drilldown import DRILLDOWN_SEARCH_PLACEHOLDER, Drilldown
|
|
40
|
+
from contentctl.objects.enums import (
|
|
41
|
+
AnalyticsType,
|
|
42
|
+
DataModel,
|
|
43
|
+
DetectionStatus,
|
|
44
|
+
NistCategory,
|
|
45
|
+
ProvidingTechnology,
|
|
46
|
+
)
|
|
47
|
+
from contentctl.objects.integration_test import IntegrationTest
|
|
48
|
+
from contentctl.objects.manual_test import ManualTest
|
|
49
|
+
from contentctl.objects.rba import RBAObject
|
|
50
|
+
from contentctl.objects.security_content_object import SecurityContentObject
|
|
51
|
+
from contentctl.objects.test_group import TestGroup
|
|
52
|
+
from contentctl.objects.unit_test import UnitTest
|
|
52
53
|
|
|
53
54
|
MISSING_SOURCES: set[str] = set()
|
|
54
55
|
|
|
55
56
|
# Those AnalyticsTypes that we do not test via contentctl
|
|
56
|
-
SKIPPED_ANALYTICS_TYPES: set[str] = {
|
|
57
|
-
AnalyticsType.Correlation
|
|
58
|
-
}
|
|
57
|
+
SKIPPED_ANALYTICS_TYPES: set[str] = {AnalyticsType.Correlation}
|
|
59
58
|
|
|
60
59
|
|
|
61
60
|
class Detection_Abstract(SecurityContentObject):
|
|
62
|
-
name:str = Field(...,max_length=CONTENTCTL_MAX_SEARCH_NAME_LENGTH)
|
|
63
|
-
#contentType: SecurityContentType = SecurityContentType.detections
|
|
61
|
+
name: str = Field(..., max_length=CONTENTCTL_MAX_SEARCH_NAME_LENGTH)
|
|
62
|
+
# contentType: SecurityContentType = SecurityContentType.detections
|
|
64
63
|
type: AnalyticsType = Field(...)
|
|
65
64
|
status: DetectionStatus = Field(...)
|
|
66
65
|
data_source: list[str] = []
|
|
@@ -71,16 +70,15 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
71
70
|
rba: Optional[RBAObject] = Field(default=None)
|
|
72
71
|
explanation: None | str = Field(
|
|
73
72
|
default=None,
|
|
74
|
-
exclude=True,
|
|
73
|
+
exclude=True, # Don't serialize this value when dumping the object
|
|
75
74
|
description="Provide an explanation to be included "
|
|
76
75
|
"in the 'Explanation' field of the Detection in "
|
|
77
76
|
"the Use Case Library. If this field is not "
|
|
78
77
|
"defined in the YML, it will default to the "
|
|
79
|
-
"value of the 'description' field when "
|
|
78
|
+
"value of the 'description' field when "
|
|
80
79
|
"serialized in analyticstories_detections.j2",
|
|
81
80
|
)
|
|
82
81
|
|
|
83
|
-
|
|
84
82
|
enabled_by_default: bool = False
|
|
85
83
|
file_path: FilePath = Field(...)
|
|
86
84
|
# For model construction to first attempt construction of the leftmost object.
|
|
@@ -88,36 +86,49 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
88
86
|
# default mode, 'smart'
|
|
89
87
|
# https://docs.pydantic.dev/latest/concepts/unions/#left-to-right-mode
|
|
90
88
|
# https://github.com/pydantic/pydantic/issues/9101#issuecomment-2019032541
|
|
91
|
-
tests: List[
|
|
89
|
+
tests: List[
|
|
90
|
+
Annotated[
|
|
91
|
+
Union[UnitTest, IntegrationTest, ManualTest],
|
|
92
|
+
Field(union_mode="left_to_right"),
|
|
93
|
+
]
|
|
94
|
+
] = []
|
|
92
95
|
# A list of groups of tests, relying on the same data
|
|
93
96
|
test_groups: list[TestGroup] = []
|
|
94
97
|
|
|
95
98
|
data_source_objects: list[DataSource] = []
|
|
96
|
-
drilldown_searches: list[Drilldown] = Field(
|
|
99
|
+
drilldown_searches: list[Drilldown] = Field(
|
|
100
|
+
default=[],
|
|
101
|
+
description="A list of Drilldowns that should be included with this search",
|
|
102
|
+
)
|
|
97
103
|
|
|
98
|
-
def get_conf_stanza_name(self, app:CustomApp)->str:
|
|
99
|
-
stanza_name = CONTENTCTL_DETECTION_STANZA_NAME_FORMAT_TEMPLATE.format(
|
|
104
|
+
def get_conf_stanza_name(self, app: CustomApp) -> str:
|
|
105
|
+
stanza_name = CONTENTCTL_DETECTION_STANZA_NAME_FORMAT_TEMPLATE.format(
|
|
106
|
+
app_label=app.label, detection_name=self.name
|
|
107
|
+
)
|
|
100
108
|
self.check_conf_stanza_max_length(stanza_name)
|
|
101
109
|
return stanza_name
|
|
102
|
-
|
|
103
110
|
|
|
104
|
-
def get_action_dot_correlationsearch_dot_label(
|
|
111
|
+
def get_action_dot_correlationsearch_dot_label(
|
|
112
|
+
self, app: CustomApp, max_stanza_length: int = ES_MAX_STANZA_LENGTH
|
|
113
|
+
) -> str:
|
|
105
114
|
stanza_name = self.get_conf_stanza_name(app)
|
|
106
|
-
stanza_name_after_saving_in_es =
|
|
107
|
-
|
|
108
|
-
|
|
115
|
+
stanza_name_after_saving_in_es = (
|
|
116
|
+
ES_SEARCH_STANZA_NAME_FORMAT_AFTER_CLONING_IN_PRODUCT_TEMPLATE.format(
|
|
117
|
+
security_domain_value=self.tags.security_domain, search_name=stanza_name
|
|
109
118
|
)
|
|
110
|
-
|
|
111
|
-
|
|
119
|
+
)
|
|
120
|
+
|
|
112
121
|
if len(stanza_name_after_saving_in_es) > max_stanza_length:
|
|
113
|
-
raise ValueError(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
122
|
+
raise ValueError(
|
|
123
|
+
f"label may only be {max_stanza_length} characters to allow updating in-product, "
|
|
124
|
+
f"but stanza was actually {len(stanza_name_after_saving_in_es)} characters: '{stanza_name_after_saving_in_es}' "
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return stanza_name
|
|
117
128
|
|
|
118
129
|
@field_validator("search", mode="before")
|
|
119
130
|
@classmethod
|
|
120
|
-
def validate_presence_of_filter_macro(cls, value:str, info:ValidationInfo)->str:
|
|
131
|
+
def validate_presence_of_filter_macro(cls, value: str, info: ValidationInfo) -> str:
|
|
121
132
|
"""
|
|
122
133
|
Validates that, if required to be present, the filter macro is present with the proper name.
|
|
123
134
|
The filter macro MUST be derived from the name of the detection
|
|
@@ -131,7 +142,7 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
131
142
|
Returns:
|
|
132
143
|
str: The search, as an SPL formatted string.
|
|
133
144
|
"""
|
|
134
|
-
|
|
145
|
+
|
|
135
146
|
# Otherwise, the search is SPL.
|
|
136
147
|
|
|
137
148
|
# In the future, we will may add support that makes the inclusion of the
|
|
@@ -171,7 +182,7 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
171
182
|
the model from the list of unit tests. Also, preemptively skips all manual tests, as well as
|
|
172
183
|
tests for experimental/deprecated detections and Correlation type detections.
|
|
173
184
|
"""
|
|
174
|
-
|
|
185
|
+
|
|
175
186
|
# Since ManualTest and UnitTest are not differentiable without looking at the manual_test
|
|
176
187
|
# tag, Pydantic builds all tests as UnitTest objects. If we see the manual_test flag, we
|
|
177
188
|
# convert these to ManualTest
|
|
@@ -184,10 +195,7 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
184
195
|
f"but encountered a {type(test)}."
|
|
185
196
|
)
|
|
186
197
|
# Create the manual test and skip it upon creation (cannot test via contentctl)
|
|
187
|
-
manual_test = ManualTest(
|
|
188
|
-
name=test.name,
|
|
189
|
-
attack_data=test.attack_data
|
|
190
|
-
)
|
|
198
|
+
manual_test = ManualTest(name=test.name, attack_data=test.attack_data)
|
|
191
199
|
tmp.append(manual_test)
|
|
192
200
|
self.tests = tmp
|
|
193
201
|
|
|
@@ -213,8 +221,10 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
213
221
|
# https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
|
|
214
222
|
|
|
215
223
|
# Skip tests for non-production detections
|
|
216
|
-
if self.status != DetectionStatus.production:
|
|
217
|
-
self.skip_all_tests(
|
|
224
|
+
if self.status != DetectionStatus.production:
|
|
225
|
+
self.skip_all_tests(
|
|
226
|
+
f"TEST SKIPPED: Detection is non-production ({self.status})"
|
|
227
|
+
)
|
|
218
228
|
|
|
219
229
|
# Skip tests for detecton types like Correlation which are not supported via contentctl
|
|
220
230
|
if self.type in SKIPPED_ANALYTICS_TYPES:
|
|
@@ -241,7 +251,10 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
241
251
|
# If the result/status of any test has not yet been set, return None
|
|
242
252
|
if test.result is None or test.result.status is None:
|
|
243
253
|
return None
|
|
244
|
-
elif
|
|
254
|
+
elif (
|
|
255
|
+
test.result.status == TestResultStatus.ERROR
|
|
256
|
+
or test.result.status == TestResultStatus.FAIL
|
|
257
|
+
):
|
|
245
258
|
# If any test failed or errored, return fail (we don't return the error state at
|
|
246
259
|
# the aggregate detection level)
|
|
247
260
|
return TestResultStatus.FAIL
|
|
@@ -267,24 +280,21 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
267
280
|
@property
|
|
268
281
|
def datamodel(self) -> List[DataModel]:
|
|
269
282
|
return [dm for dm in DataModel if dm in self.search]
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
283
|
|
|
274
284
|
@computed_field
|
|
275
285
|
@property
|
|
276
286
|
def source(self) -> str:
|
|
277
287
|
return self.file_path.absolute().parent.name
|
|
278
|
-
|
|
279
288
|
|
|
280
289
|
deployment: Deployment = Field({})
|
|
281
290
|
|
|
282
291
|
@computed_field
|
|
283
292
|
@property
|
|
284
293
|
def annotations(self) -> dict[str, Union[List[str], int, str]]:
|
|
285
|
-
|
|
286
294
|
annotations_dict: dict[str, str | list[str] | int] = {}
|
|
287
|
-
annotations_dict["analytic_story"] = [
|
|
295
|
+
annotations_dict["analytic_story"] = [
|
|
296
|
+
story.name for story in self.tags.analytic_story
|
|
297
|
+
]
|
|
288
298
|
if len(self.tags.cve or []) > 0:
|
|
289
299
|
annotations_dict["cve"] = self.tags.cve
|
|
290
300
|
annotations_dict["type"] = self.type
|
|
@@ -311,11 +321,13 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
311
321
|
if len(self.tags.cis20) > 0:
|
|
312
322
|
mappings["cis20"] = [tag for tag in self.tags.cis20]
|
|
313
323
|
if len(self.tags.kill_chain_phases) > 0:
|
|
314
|
-
mappings[
|
|
324
|
+
mappings["kill_chain_phases"] = [
|
|
325
|
+
phase for phase in self.tags.kill_chain_phases
|
|
326
|
+
]
|
|
315
327
|
if len(self.tags.mitre_attack_id) > 0:
|
|
316
|
-
mappings[
|
|
328
|
+
mappings["mitre_attack"] = self.tags.mitre_attack_id
|
|
317
329
|
if len(self.tags.nist) > 0:
|
|
318
|
-
mappings[
|
|
330
|
+
mappings["nist"] = [category for category in self.tags.nist]
|
|
319
331
|
|
|
320
332
|
# No need to sort the dict! It has been constructed in-order.
|
|
321
333
|
# However, if this logic is changed, then consider reordering or
|
|
@@ -330,8 +342,10 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
330
342
|
|
|
331
343
|
def cve_enrichment_func(self, __context: Any):
|
|
332
344
|
if len(self.cve_enrichment) > 0:
|
|
333
|
-
raise ValueError(
|
|
334
|
-
|
|
345
|
+
raise ValueError(
|
|
346
|
+
f"Error, field 'cve_enrichment' should be empty and "
|
|
347
|
+
f"dynamically populated at runtime. Instead, this field contained: {self.cve_enrichment}"
|
|
348
|
+
)
|
|
335
349
|
|
|
336
350
|
output_dto: Union[DirectorOutputDto, None] = __context.get("output_dto", None)
|
|
337
351
|
if output_dto is None:
|
|
@@ -341,7 +355,11 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
341
355
|
|
|
342
356
|
for cve_id in self.tags.cve:
|
|
343
357
|
try:
|
|
344
|
-
enriched_cves.append(
|
|
358
|
+
enriched_cves.append(
|
|
359
|
+
output_dto.cve_enrichment.enrich_cve(
|
|
360
|
+
cve_id, raise_exception_on_failure=False
|
|
361
|
+
)
|
|
362
|
+
)
|
|
345
363
|
except Exception as e:
|
|
346
364
|
raise ValueError(f"{e}")
|
|
347
365
|
self.cve_enrichment = enriched_cves
|
|
@@ -353,7 +371,7 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
353
371
|
@property
|
|
354
372
|
def nes_fields(self) -> Optional[str]:
|
|
355
373
|
if self.deployment.alert_action.notable is not None:
|
|
356
|
-
return
|
|
374
|
+
return ",".join(self.deployment.alert_action.notable.nes_fields)
|
|
357
375
|
else:
|
|
358
376
|
return None
|
|
359
377
|
|
|
@@ -362,7 +380,6 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
362
380
|
def providing_technologies(self) -> List[ProvidingTechnology]:
|
|
363
381
|
return ProvidingTechnology.getProvidingTechFromSearch(self.search)
|
|
364
382
|
|
|
365
|
-
|
|
366
383
|
@computed_field
|
|
367
384
|
@property
|
|
368
385
|
def risk(self) -> list[dict[str, Any]]:
|
|
@@ -370,83 +387,21 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
370
387
|
|
|
371
388
|
for entity in self.rba.risk_objects:
|
|
372
389
|
risk_object: dict[str, str | int] = dict()
|
|
373
|
-
risk_object[
|
|
374
|
-
risk_object[
|
|
375
|
-
risk_object[
|
|
390
|
+
risk_object["risk_object_type"] = entity.type
|
|
391
|
+
risk_object["risk_object_field"] = entity.field
|
|
392
|
+
risk_object["risk_score"] = entity.score
|
|
376
393
|
risk_objects.append(risk_object)
|
|
377
|
-
|
|
394
|
+
|
|
378
395
|
for entity in self.rba.threat_objects:
|
|
379
396
|
threat_object: dict[str, str] = dict()
|
|
380
|
-
threat_object[
|
|
381
|
-
threat_object[
|
|
397
|
+
threat_object["threat_object_field"] = entity.field
|
|
398
|
+
threat_object["threat_object_type"] = entity.type
|
|
382
399
|
risk_objects.append(threat_object)
|
|
383
400
|
return risk_objects
|
|
384
401
|
|
|
385
|
-
|
|
386
|
-
# TODO Remove observable code
|
|
387
|
-
# @computed_field
|
|
388
|
-
# @property
|
|
389
|
-
# def risk(self) -> list[dict[str, Any]]:
|
|
390
|
-
# risk_objects: list[dict[str, str | int]] = []
|
|
391
|
-
# # TODO (#246): "User Name" type should map to a "user" risk object and not "other"
|
|
392
|
-
# risk_object_user_types = {'user', 'username', 'email address'}
|
|
393
|
-
# risk_object_system_types = {'device', 'endpoint', 'hostname', 'ip address'}
|
|
394
|
-
# process_threat_object_types = {'process name', 'process'}
|
|
395
|
-
# file_threat_object_types = {'file name', 'file', 'file hash'}
|
|
396
|
-
# url_threat_object_types = {'url string', 'url'}
|
|
397
|
-
# ip_threat_object_types = {'ip address'}
|
|
398
|
-
|
|
399
|
-
# for entity in self.tags.observable:
|
|
400
|
-
# risk_object: dict[str, str | int] = dict()
|
|
401
|
-
# if 'Victim' in entity.role and entity.type.lower() in risk_object_user_types:
|
|
402
|
-
# risk_object['risk_object_type'] = 'user'
|
|
403
|
-
# risk_object['risk_object_field'] = entity.name
|
|
404
|
-
# risk_object['risk_score'] = self.tags.risk_score
|
|
405
|
-
# risk_objects.append(risk_object)
|
|
406
|
-
|
|
407
|
-
# elif 'Victim' in entity.role and entity.type.lower() in risk_object_system_types:
|
|
408
|
-
# risk_object['risk_object_type'] = 'system'
|
|
409
|
-
# risk_object['risk_object_field'] = entity.name
|
|
410
|
-
# risk_object['risk_score'] = self.tags.risk_score
|
|
411
|
-
# risk_objects.append(risk_object)
|
|
412
|
-
|
|
413
|
-
# elif 'Attacker' in entity.role and entity.type.lower() in process_threat_object_types:
|
|
414
|
-
# risk_object['threat_object_field'] = entity.name
|
|
415
|
-
# risk_object['threat_object_type'] = "process"
|
|
416
|
-
# risk_objects.append(risk_object)
|
|
417
|
-
|
|
418
|
-
# elif 'Attacker' in entity.role and entity.type.lower() in file_threat_object_types:
|
|
419
|
-
# risk_object['threat_object_field'] = entity.name
|
|
420
|
-
# risk_object['threat_object_type'] = "file_name"
|
|
421
|
-
# risk_objects.append(risk_object)
|
|
422
|
-
|
|
423
|
-
# elif 'Attacker' in entity.role and entity.type.lower() in ip_threat_object_types:
|
|
424
|
-
# risk_object['threat_object_field'] = entity.name
|
|
425
|
-
# risk_object['threat_object_type'] = "ip_address"
|
|
426
|
-
# risk_objects.append(risk_object)
|
|
427
|
-
|
|
428
|
-
# elif 'Attacker' in entity.role and entity.type.lower() in url_threat_object_types:
|
|
429
|
-
# risk_object['threat_object_field'] = entity.name
|
|
430
|
-
# risk_object['threat_object_type'] = "url"
|
|
431
|
-
# risk_objects.append(risk_object)
|
|
432
|
-
|
|
433
|
-
# elif 'Attacker' in entity.role:
|
|
434
|
-
# risk_object['threat_object_field'] = entity.name
|
|
435
|
-
# risk_object['threat_object_type'] = entity.type.lower()
|
|
436
|
-
# risk_objects.append(risk_object)
|
|
437
|
-
|
|
438
|
-
# else:
|
|
439
|
-
# risk_object['risk_object_type'] = 'other'
|
|
440
|
-
# risk_object['risk_object_field'] = entity.name
|
|
441
|
-
# risk_object['risk_score'] = self.tags.risk_score
|
|
442
|
-
# risk_objects.append(risk_object)
|
|
443
|
-
# continue
|
|
444
|
-
|
|
445
|
-
# return risk_objects
|
|
446
|
-
|
|
447
402
|
@computed_field
|
|
448
403
|
@property
|
|
449
|
-
def metadata(self) -> dict[str, str|float]:
|
|
404
|
+
def metadata(self) -> dict[str, str | float]:
|
|
450
405
|
# NOTE: we ignore the type error around self.status because we are using Pydantic's
|
|
451
406
|
# use_enum_values configuration
|
|
452
407
|
# https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
|
|
@@ -456,10 +411,19 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
456
411
|
# dict below) should not have any impact, but renaming or removing any of these fields will
|
|
457
412
|
# break the `inspect` action.
|
|
458
413
|
return {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
414
|
+
"detection_id": str(self.id),
|
|
415
|
+
"deprecated": "1" if self.status == DetectionStatus.deprecated else "0", # type: ignore
|
|
416
|
+
"detection_version": str(self.version),
|
|
417
|
+
"publish_time": datetime.datetime(
|
|
418
|
+
self.date.year,
|
|
419
|
+
self.date.month,
|
|
420
|
+
self.date.day,
|
|
421
|
+
0,
|
|
422
|
+
0,
|
|
423
|
+
0,
|
|
424
|
+
0,
|
|
425
|
+
tzinfo=datetime.timezone.utc,
|
|
426
|
+
).timestamp(),
|
|
463
427
|
}
|
|
464
428
|
|
|
465
429
|
@model_serializer
|
|
@@ -480,9 +444,9 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
480
444
|
}
|
|
481
445
|
if self.rba is not None:
|
|
482
446
|
model["risk_severity"] = self.rba.severity
|
|
483
|
-
model[
|
|
447
|
+
model["tags"]["risk_score"] = self.rba.risk_score
|
|
484
448
|
else:
|
|
485
|
-
model[
|
|
449
|
+
model["tags"]["risk_score"] = 0
|
|
486
450
|
|
|
487
451
|
# Only a subset of macro fields are required:
|
|
488
452
|
all_macros: list[dict[str, str | list[str]]] = []
|
|
@@ -490,13 +454,13 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
490
454
|
macro_dump: dict[str, str | list[str]] = {
|
|
491
455
|
"name": macro.name,
|
|
492
456
|
"definition": macro.definition,
|
|
493
|
-
"description": macro.description
|
|
457
|
+
"description": macro.description,
|
|
494
458
|
}
|
|
495
459
|
if len(macro.arguments) > 0:
|
|
496
|
-
macro_dump[
|
|
460
|
+
macro_dump["arguments"] = macro.arguments
|
|
497
461
|
|
|
498
462
|
all_macros.append(macro_dump)
|
|
499
|
-
model[
|
|
463
|
+
model["macros"] = all_macros # type: ignore
|
|
500
464
|
|
|
501
465
|
all_lookups: list[dict[str, str | int | None]] = []
|
|
502
466
|
for lookup in self.lookups:
|
|
@@ -507,7 +471,7 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
507
471
|
"description": lookup.description,
|
|
508
472
|
"collection": lookup.collection,
|
|
509
473
|
"case_sensitive_match": None,
|
|
510
|
-
"fields_list": lookup.fields_to_fields_list_conf_format
|
|
474
|
+
"fields_list": lookup.fields_to_fields_list_conf_format,
|
|
511
475
|
}
|
|
512
476
|
)
|
|
513
477
|
elif isinstance(lookup, FileBackedLookup):
|
|
@@ -517,15 +481,17 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
517
481
|
"description": lookup.description,
|
|
518
482
|
"filename": lookup.filename.name,
|
|
519
483
|
"default_match": "true" if lookup.default_match else "false",
|
|
520
|
-
"case_sensitive_match": "true"
|
|
484
|
+
"case_sensitive_match": "true"
|
|
485
|
+
if lookup.case_sensitive_match
|
|
486
|
+
else "false",
|
|
521
487
|
"match_type": lookup.match_type_to_conf_format,
|
|
522
|
-
"min_matches": lookup.min_matches
|
|
488
|
+
"min_matches": lookup.min_matches,
|
|
523
489
|
}
|
|
524
490
|
)
|
|
525
|
-
model[
|
|
491
|
+
model["lookups"] = all_lookups # type: ignore
|
|
526
492
|
|
|
527
493
|
# Combine fields from this model with fields from parent
|
|
528
|
-
super_fields.update(model)
|
|
494
|
+
super_fields.update(model) # type: ignore
|
|
529
495
|
|
|
530
496
|
# return the model
|
|
531
497
|
return super_fields
|
|
@@ -558,7 +524,7 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
558
524
|
updated_data_source_names: set[str] = set()
|
|
559
525
|
|
|
560
526
|
for ds in self.data_source:
|
|
561
|
-
split_data_sources = {d.strip() for d in ds.split(
|
|
527
|
+
split_data_sources = {d.strip() for d in ds.split("AND")}
|
|
562
528
|
updated_data_source_names.update(split_data_sources)
|
|
563
529
|
|
|
564
530
|
sources = sorted(list(updated_data_source_names))
|
|
@@ -567,7 +533,9 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
567
533
|
missing_sources: list[str] = []
|
|
568
534
|
for source in sources:
|
|
569
535
|
try:
|
|
570
|
-
matched_data_sources += DataSource.mapNamesToSecurityContentObjects(
|
|
536
|
+
matched_data_sources += DataSource.mapNamesToSecurityContentObjects(
|
|
537
|
+
[source], director
|
|
538
|
+
)
|
|
571
539
|
except Exception:
|
|
572
540
|
# We gobble this up and add it to a global set so that we
|
|
573
541
|
# can print it ONCE at the end of the build of datasources.
|
|
@@ -584,7 +552,7 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
584
552
|
self.data_source_objects = matched_data_sources
|
|
585
553
|
|
|
586
554
|
for story in self.tags.analytic_story:
|
|
587
|
-
story.detections.append(self)
|
|
555
|
+
story.detections.append(self)
|
|
588
556
|
|
|
589
557
|
self.cve_enrichment_func(__context)
|
|
590
558
|
|
|
@@ -595,32 +563,39 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
595
563
|
# 1 of the drilldowns contains the string Drilldown.SEARCH_PLACEHOLDER.
|
|
596
564
|
# This is presently a requirement when 1 or more drilldowns are added to a detection.
|
|
597
565
|
# Note that this is only required for production searches that are not hunting
|
|
598
|
-
|
|
599
|
-
if
|
|
600
|
-
|
|
566
|
+
|
|
567
|
+
if (
|
|
568
|
+
self.type == AnalyticsType.Hunting
|
|
569
|
+
or self.status != DetectionStatus.production
|
|
570
|
+
):
|
|
571
|
+
# No additional check need to happen on the potential drilldowns.
|
|
601
572
|
pass
|
|
602
573
|
else:
|
|
603
574
|
found_placeholder = False
|
|
604
575
|
if len(self.drilldown_searches) < 2:
|
|
605
|
-
raise ValueError(
|
|
576
|
+
raise ValueError(
|
|
577
|
+
f"This detection is required to have 2 drilldown_searches, but only has [{len(self.drilldown_searches)}]"
|
|
578
|
+
)
|
|
606
579
|
for drilldown in self.drilldown_searches:
|
|
607
580
|
if DRILLDOWN_SEARCH_PLACEHOLDER in drilldown.search:
|
|
608
581
|
found_placeholder = True
|
|
609
582
|
if not found_placeholder:
|
|
610
|
-
raise ValueError(
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
583
|
+
raise ValueError(
|
|
584
|
+
"Detection has one or more drilldown_searches, but none of them "
|
|
585
|
+
f"contained '{DRILLDOWN_SEARCH_PLACEHOLDER}. This is a requirement "
|
|
586
|
+
"if drilldown_searches are defined.'"
|
|
587
|
+
)
|
|
588
|
+
|
|
614
589
|
# Update the search fields with the original search, if required
|
|
615
590
|
for drilldown in self.drilldown_searches:
|
|
616
591
|
drilldown.perform_search_substitutions(self)
|
|
617
592
|
|
|
618
|
-
#For experimental purposes, add the default drilldowns
|
|
619
|
-
#self.drilldown_searches.extend(Drilldown.constructDrilldownsFromDetection(self))
|
|
593
|
+
# For experimental purposes, add the default drilldowns
|
|
594
|
+
# self.drilldown_searches.extend(Drilldown.constructDrilldownsFromDetection(self))
|
|
620
595
|
|
|
621
596
|
@property
|
|
622
|
-
def drilldowns_in_JSON(self) -> list[dict[str,str]]:
|
|
623
|
-
"""This function is required for proper JSON
|
|
597
|
+
def drilldowns_in_JSON(self) -> list[dict[str, str]]:
|
|
598
|
+
"""This function is required for proper JSON
|
|
624
599
|
serializiation of drilldowns to occur in savedsearches.conf.
|
|
625
600
|
It returns the list[Drilldown] as a list[dict].
|
|
626
601
|
Without this function, the jinja template is unable
|
|
@@ -628,24 +603,26 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
628
603
|
|
|
629
604
|
Returns:
|
|
630
605
|
list[dict[str,str]]: List of Drilldowns dumped to dict format
|
|
631
|
-
"""
|
|
606
|
+
"""
|
|
632
607
|
return [drilldown.model_dump() for drilldown in self.drilldown_searches]
|
|
633
608
|
|
|
634
|
-
@field_validator(
|
|
609
|
+
@field_validator("lookups", mode="before")
|
|
635
610
|
@classmethod
|
|
636
|
-
def getDetectionLookups(cls, v:list[str], info:ValidationInfo) -> list[Lookup]:
|
|
637
|
-
director:DirectorOutputDto = info.context.get("output_dto",None)
|
|
638
|
-
|
|
639
|
-
search:Union[str,None] = info.data.get("search",None)
|
|
611
|
+
def getDetectionLookups(cls, v: list[str], info: ValidationInfo) -> list[Lookup]:
|
|
612
|
+
director: DirectorOutputDto = info.context.get("output_dto", None)
|
|
613
|
+
|
|
614
|
+
search: Union[str, None] = info.data.get("search", None)
|
|
640
615
|
if search is None:
|
|
641
616
|
raise ValueError("Search was None - is this file missing the search field?")
|
|
642
|
-
|
|
617
|
+
|
|
643
618
|
lookups = Lookup.get_lookups(search, director)
|
|
644
619
|
return lookups
|
|
645
620
|
|
|
646
|
-
@field_validator(
|
|
621
|
+
@field_validator("baselines", mode="before")
|
|
647
622
|
@classmethod
|
|
648
|
-
def mapDetectionNamesToBaselineObjects(
|
|
623
|
+
def mapDetectionNamesToBaselineObjects(
|
|
624
|
+
cls, v: list[str], info: ValidationInfo
|
|
625
|
+
) -> List[Baseline]:
|
|
649
626
|
if len(v) > 0:
|
|
650
627
|
raise ValueError(
|
|
651
628
|
"Error, baselines are constructed automatically at runtime. Please do not include this field."
|
|
@@ -653,7 +630,9 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
653
630
|
|
|
654
631
|
name: Union[str, None] = info.data.get("name", None)
|
|
655
632
|
if name is None:
|
|
656
|
-
raise ValueError(
|
|
633
|
+
raise ValueError(
|
|
634
|
+
"Error, cannot get Baselines because the Detection does not have a 'name' defined."
|
|
635
|
+
)
|
|
657
636
|
|
|
658
637
|
if info.context is None:
|
|
659
638
|
raise ValueError("ValidationInfo.context unexpectedly null")
|
|
@@ -664,14 +643,16 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
664
643
|
# This matching is a bit strange, because baseline.tags.detections starts as a list of strings, but
|
|
665
644
|
# is eventually updated to a list of Detections as we construct all of the detection objects.
|
|
666
645
|
detection_names = [
|
|
667
|
-
detection_name
|
|
646
|
+
detection_name
|
|
647
|
+
for detection_name in baseline.tags.detections
|
|
648
|
+
if isinstance(detection_name, str)
|
|
668
649
|
]
|
|
669
650
|
if name in detection_names:
|
|
670
651
|
baselines.append(baseline)
|
|
671
652
|
|
|
672
653
|
return baselines
|
|
673
654
|
|
|
674
|
-
@field_validator(
|
|
655
|
+
@field_validator("macros", mode="before")
|
|
675
656
|
@classmethod
|
|
676
657
|
def getDetectionMacros(cls, v: list[str], info: ValidationInfo) -> list[Macro]:
|
|
677
658
|
if info.context is None:
|
|
@@ -687,21 +668,25 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
687
668
|
message = f"Expected 'search_name' to be a string, instead it was [{type(search_name)}]"
|
|
688
669
|
assert isinstance(search_name, str), message
|
|
689
670
|
|
|
690
|
-
filter_macro_name =
|
|
691
|
-
.replace(
|
|
692
|
-
.replace(
|
|
693
|
-
.replace(
|
|
694
|
-
.
|
|
695
|
-
|
|
671
|
+
filter_macro_name = (
|
|
672
|
+
search_name.replace(" ", "_")
|
|
673
|
+
.replace("-", "_")
|
|
674
|
+
.replace(".", "_")
|
|
675
|
+
.replace("/", "_")
|
|
676
|
+
.lower()
|
|
677
|
+
+ "_filter"
|
|
678
|
+
)
|
|
696
679
|
try:
|
|
697
|
-
filter_macro = Macro.mapNamesToSecurityContentObjects(
|
|
680
|
+
filter_macro = Macro.mapNamesToSecurityContentObjects(
|
|
681
|
+
[filter_macro_name], director
|
|
682
|
+
)[0]
|
|
698
683
|
except Exception:
|
|
699
684
|
# Filter macro did not exist, so create one at runtime
|
|
700
685
|
filter_macro = Macro.model_validate(
|
|
701
686
|
{
|
|
702
687
|
"name": filter_macro_name,
|
|
703
|
-
"definition":
|
|
704
|
-
"description":
|
|
688
|
+
"definition": "search *",
|
|
689
|
+
"description": "Update this macro to limit the output results to filter out false positives.",
|
|
705
690
|
}
|
|
706
691
|
)
|
|
707
692
|
director.addContentToDictMappings(filter_macro)
|
|
@@ -724,12 +709,12 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
724
709
|
|
|
725
710
|
@field_validator("enabled_by_default", mode="before")
|
|
726
711
|
def only_enabled_if_production_status(cls, v: Any, info: ValidationInfo) -> bool:
|
|
727
|
-
|
|
712
|
+
"""
|
|
728
713
|
A detection can ONLY be enabled by default if it is a PRODUCTION detection.
|
|
729
714
|
If not (for example, it is EXPERIMENTAL or DEPRECATED) then we will throw an exception.
|
|
730
715
|
Similarly, a detection MUST be schedulable, meaning that it must be Anomaly, Correleation, or TTP.
|
|
731
716
|
We will not allow Hunting searches to be enabled by default.
|
|
732
|
-
|
|
717
|
+
"""
|
|
733
718
|
if v is False:
|
|
734
719
|
return v
|
|
735
720
|
|
|
@@ -740,16 +725,23 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
740
725
|
errors.append(
|
|
741
726
|
f"status is '{status.name}'. Detections that are enabled by default MUST be "
|
|
742
727
|
f"'{DetectionStatus.production}'"
|
|
743
|
-
|
|
728
|
+
)
|
|
744
729
|
|
|
745
|
-
if searchType not in [
|
|
730
|
+
if searchType not in [
|
|
731
|
+
AnalyticsType.Anomaly,
|
|
732
|
+
AnalyticsType.Correlation,
|
|
733
|
+
AnalyticsType.TTP,
|
|
734
|
+
]:
|
|
746
735
|
errors.append(
|
|
747
736
|
f"type is '{searchType}'. Detections that are enabled by default MUST be one"
|
|
748
737
|
" of the following types: "
|
|
749
|
-
f"{[AnalyticsType.Anomaly, AnalyticsType.Correlation, AnalyticsType.TTP]}"
|
|
738
|
+
f"{[AnalyticsType.Anomaly, AnalyticsType.Correlation, AnalyticsType.TTP]}"
|
|
739
|
+
)
|
|
750
740
|
if len(errors) > 0:
|
|
751
741
|
error_message = "\n - ".join(errors)
|
|
752
|
-
raise ValueError(
|
|
742
|
+
raise ValueError(
|
|
743
|
+
f"Detection is 'enabled_by_default: true' however \n - {error_message}"
|
|
744
|
+
)
|
|
753
745
|
|
|
754
746
|
return v
|
|
755
747
|
|
|
@@ -760,39 +752,42 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
760
752
|
else:
|
|
761
753
|
self.tags.nist = [NistCategory.DE_AE]
|
|
762
754
|
return self
|
|
763
|
-
|
|
764
755
|
|
|
765
756
|
@model_validator(mode="after")
|
|
766
757
|
def ensureThrottlingFieldsExist(self):
|
|
767
|
-
|
|
758
|
+
"""
|
|
768
759
|
For throttling to work properly, the fields to throttle on MUST
|
|
769
760
|
exist in the search itself. If not, then we cannot apply the throttling
|
|
770
|
-
|
|
761
|
+
"""
|
|
771
762
|
if self.tags.throttling is None:
|
|
772
763
|
# No throttling configured for this detection
|
|
773
764
|
return self
|
|
774
765
|
|
|
775
|
-
missing_fields:list[str] = [
|
|
766
|
+
missing_fields: list[str] = [
|
|
767
|
+
field for field in self.tags.throttling.fields if field not in self.search
|
|
768
|
+
]
|
|
776
769
|
if len(missing_fields) > 0:
|
|
777
|
-
raise ValueError(
|
|
770
|
+
raise ValueError(
|
|
771
|
+
f"The following throttle fields were missing from the search: {missing_fields}"
|
|
772
|
+
)
|
|
778
773
|
|
|
779
774
|
else:
|
|
780
775
|
# All throttling fields present in search
|
|
781
776
|
return self
|
|
782
|
-
|
|
783
|
-
|
|
784
777
|
|
|
785
778
|
@model_validator(mode="after")
|
|
786
779
|
def ensureProperRBAConfig(self):
|
|
787
780
|
"""
|
|
788
781
|
If a detection has an RBA deployment and is PRODUCTION, then it must have an RBA config, with at least one risk object
|
|
789
|
-
|
|
782
|
+
|
|
790
783
|
Returns:
|
|
791
784
|
self: Returns itself if the validation passes
|
|
792
785
|
"""
|
|
793
786
|
|
|
794
|
-
|
|
795
|
-
|
|
787
|
+
if (
|
|
788
|
+
self.deployment.alert_action.rba is None
|
|
789
|
+
or self.deployment.alert_action.rba.enabled is False
|
|
790
|
+
):
|
|
796
791
|
# confirm we don't have an RBA config
|
|
797
792
|
if self.rba is None:
|
|
798
793
|
return self
|
|
@@ -806,55 +801,23 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
806
801
|
"Detection is expected to have an RBA object based on its deployment config"
|
|
807
802
|
)
|
|
808
803
|
else:
|
|
809
|
-
if len(self.rba.risk_objects) > 0:
|
|
804
|
+
if len(self.rba.risk_objects) > 0: # type: ignore
|
|
810
805
|
return self
|
|
811
806
|
else:
|
|
812
807
|
raise ValueError(
|
|
813
808
|
"Detection expects an RBA config with at least one risk object."
|
|
814
809
|
)
|
|
815
810
|
|
|
816
|
-
|
|
817
|
-
# TODO - Remove old observable code
|
|
818
|
-
# @model_validator(mode="after")
|
|
819
|
-
# def ensureProperObservablesExist(self):
|
|
820
|
-
# """
|
|
821
|
-
# If a detections is PRODUCTION and either TTP or ANOMALY, then it MUST have an Observable with the VICTIM role.
|
|
822
|
-
|
|
823
|
-
# Returns:
|
|
824
|
-
# self: Returns itself if the valdiation passes
|
|
825
|
-
# """
|
|
826
|
-
# # NOTE: we ignore the type error around self.status because we are using Pydantic's
|
|
827
|
-
# # use_enum_values configuration
|
|
828
|
-
# # https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
|
|
829
|
-
# if self.status not in [DetectionStatus.production.value]: # type: ignore
|
|
830
|
-
# # Only perform this validation on production detections
|
|
831
|
-
# return self
|
|
832
|
-
|
|
833
|
-
# if self.type not in [AnalyticsType.TTP.value, AnalyticsType.Anomaly.value]:
|
|
834
|
-
# # Only perform this validation on TTP and Anomaly detections
|
|
835
|
-
# return self
|
|
836
|
-
|
|
837
|
-
# # Detection is required to have a victim
|
|
838
|
-
# roles: list[str] = []
|
|
839
|
-
# for observable in self.tags.observable:
|
|
840
|
-
# roles.extend(observable.role)
|
|
841
|
-
|
|
842
|
-
# if roles.count("Victim") == 0:
|
|
843
|
-
# raise ValueError(
|
|
844
|
-
# "Error, there must be AT LEAST 1 Observable with the role 'Victim' declared in "
|
|
845
|
-
# "Detection.tags.observables. However, none were found."
|
|
846
|
-
# )
|
|
847
|
-
|
|
848
|
-
# # Exactly one victim was found
|
|
849
|
-
# return self
|
|
850
|
-
|
|
851
811
|
@model_validator(mode="after")
|
|
852
812
|
def search_rba_fields_exist_validate(self):
|
|
853
813
|
# Return immediately if RBA isn't required
|
|
854
|
-
if (
|
|
814
|
+
if (
|
|
815
|
+
self.deployment.alert_action.rba.enabled is False
|
|
816
|
+
or self.deployment.alert_action.rba is None
|
|
817
|
+
) and self.rba is None: # type: ignore
|
|
855
818
|
return self
|
|
856
|
-
|
|
857
|
-
# Raise error if RBA isn't present
|
|
819
|
+
|
|
820
|
+
# Raise error if RBA isn't present
|
|
858
821
|
if self.rba is None:
|
|
859
822
|
raise ValueError(
|
|
860
823
|
"RBA is required for this detection based on its deployment config"
|
|
@@ -869,7 +832,9 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
869
832
|
if self.rba.message:
|
|
870
833
|
matches = re.findall(field_match_regex, self.rba.message.lower())
|
|
871
834
|
message_fields = [match.replace("$", "").lower() for match in matches]
|
|
872
|
-
missing_fields = set(
|
|
835
|
+
missing_fields = set(
|
|
836
|
+
[field for field in rba_fields if field not in self.search.lower()]
|
|
837
|
+
)
|
|
873
838
|
else:
|
|
874
839
|
message_fields = []
|
|
875
840
|
missing_fields = set()
|
|
@@ -880,15 +845,16 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
880
845
|
"The following fields are declared in the rba config, but do not exist in the "
|
|
881
846
|
f"search: {missing_fields}"
|
|
882
847
|
)
|
|
883
|
-
missing_fields = set(
|
|
848
|
+
missing_fields = set(
|
|
849
|
+
[field for field in message_fields if field not in self.search.lower()]
|
|
850
|
+
)
|
|
884
851
|
if len(missing_fields) > 0:
|
|
885
852
|
error_messages.append(
|
|
886
853
|
"The following fields are used as fields in the message, but do not exist in "
|
|
887
854
|
f"the search: {missing_fields}"
|
|
888
855
|
)
|
|
889
856
|
|
|
890
|
-
if len(error_messages) > 0 and self.status == DetectionStatus.production:
|
|
891
|
-
|
|
857
|
+
if len(error_messages) > 0 and self.status == DetectionStatus.production:
|
|
892
858
|
msg = (
|
|
893
859
|
"Use of fields in rba/messages that do not appear in search:\n\t- "
|
|
894
860
|
"\n\t- ".join(error_messages)
|
|
@@ -896,52 +862,8 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
896
862
|
raise ValueError(msg)
|
|
897
863
|
return self
|
|
898
864
|
|
|
899
|
-
# TODO: Remove old observable code
|
|
900
|
-
# @model_validator(mode="after")
|
|
901
|
-
# def search_observables_exist_validate(self):
|
|
902
|
-
# observable_fields = [ob.name.lower() for ob in self.tags.observable]
|
|
903
|
-
|
|
904
|
-
# # All $field$ fields from the message must appear in the search
|
|
905
|
-
# field_match_regex = r"\$([^\s.]*)\$"
|
|
906
|
-
|
|
907
|
-
# missing_fields: set[str]
|
|
908
|
-
# if self.tags.message:
|
|
909
|
-
# matches = re.findall(field_match_regex, self.tags.message.lower())
|
|
910
|
-
# message_fields = [match.replace("$", "").lower() for match in matches]
|
|
911
|
-
# missing_fields = set([field for field in observable_fields if field not in self.search.lower()])
|
|
912
|
-
# else:
|
|
913
|
-
# message_fields = []
|
|
914
|
-
# missing_fields = set()
|
|
915
|
-
|
|
916
|
-
# error_messages: list[str] = []
|
|
917
|
-
# if len(missing_fields) > 0:
|
|
918
|
-
# error_messages.append(
|
|
919
|
-
# "The following fields are declared as observables, but do not exist in the "
|
|
920
|
-
# f"search: {missing_fields}"
|
|
921
|
-
# )
|
|
922
|
-
|
|
923
|
-
# missing_fields = set([field for field in message_fields if field not in self.search.lower()])
|
|
924
|
-
# if len(missing_fields) > 0:
|
|
925
|
-
# error_messages.append(
|
|
926
|
-
# "The following fields are used as fields in the message, but do not exist in "
|
|
927
|
-
# f"the search: {missing_fields}"
|
|
928
|
-
# )
|
|
929
|
-
|
|
930
|
-
# # NOTE: we ignore the type error around self.status because we are using Pydantic's
|
|
931
|
-
# # use_enum_values configuration
|
|
932
|
-
# # https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
|
|
933
|
-
# if len(error_messages) > 0 and self.status == DetectionStatus.production.value: # type: ignore
|
|
934
|
-
# msg = (
|
|
935
|
-
# "Use of fields in observables/messages that do not appear in search:\n\t- "
|
|
936
|
-
# "\n\t- ".join(error_messages)
|
|
937
|
-
# )
|
|
938
|
-
# raise ValueError(msg)
|
|
939
|
-
|
|
940
|
-
# # Found everything
|
|
941
|
-
# return self
|
|
942
|
-
|
|
943
865
|
@field_validator("tests", mode="before")
|
|
944
|
-
def ensure_yml_test_is_unittest(cls, v:list[dict]):
|
|
866
|
+
def ensure_yml_test_is_unittest(cls, v: list[dict]):
|
|
945
867
|
"""The typing for the tests field allows it to be one of
|
|
946
868
|
a number of different types of tests. However, ONLY
|
|
947
869
|
UnitTest should be allowed to be defined in the YML
|
|
@@ -957,17 +879,17 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
957
879
|
it into a different type of test
|
|
958
880
|
|
|
959
881
|
Args:
|
|
960
|
-
v (list[dict]): list of dicts read from the yml.
|
|
882
|
+
v (list[dict]): list of dicts read from the yml.
|
|
961
883
|
Each one SHOULD be a valid UnitTest. If we cannot
|
|
962
884
|
construct a valid unitTest from it, a ValueError should be raised
|
|
963
885
|
|
|
964
886
|
Returns:
|
|
965
|
-
_type_: The input of the function, assuming no
|
|
887
|
+
_type_: The input of the function, assuming no
|
|
966
888
|
ValueError is raised.
|
|
967
|
-
"""
|
|
968
|
-
valueErrors:list[ValueError] = []
|
|
889
|
+
"""
|
|
890
|
+
valueErrors: list[ValueError] = []
|
|
969
891
|
for unitTest in v:
|
|
970
|
-
#This raises a ValueError on a failed UnitTest.
|
|
892
|
+
# This raises a ValueError on a failed UnitTest.
|
|
971
893
|
try:
|
|
972
894
|
UnitTest.model_validate(unitTest)
|
|
973
895
|
except ValueError as e:
|
|
@@ -977,13 +899,10 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
977
899
|
# All of these can be constructred as UnitTests with no
|
|
978
900
|
# Exceptions, so let the normal flow continue
|
|
979
901
|
return v
|
|
980
|
-
|
|
981
902
|
|
|
982
903
|
@field_validator("tests")
|
|
983
904
|
def tests_validate(
|
|
984
|
-
cls,
|
|
985
|
-
v: list[UnitTest | IntegrationTest | ManualTest],
|
|
986
|
-
info: ValidationInfo
|
|
905
|
+
cls, v: list[UnitTest | IntegrationTest | ManualTest], info: ValidationInfo
|
|
987
906
|
) -> list[UnitTest | IntegrationTest | ManualTest]:
|
|
988
907
|
# Only production analytics require tests
|
|
989
908
|
if info.data.get("status", "") != DetectionStatus.production:
|
|
@@ -1003,7 +922,8 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
1003
922
|
# Ensure that there is at least 1 test
|
|
1004
923
|
if len(v) == 0:
|
|
1005
924
|
raise ValueError(
|
|
1006
|
-
"At least one test is REQUIRED for production detection: "
|
|
925
|
+
"At least one test is REQUIRED for production detection: "
|
|
926
|
+
+ info.data.get("name", "NO NAME FOUND")
|
|
1007
927
|
)
|
|
1008
928
|
|
|
1009
929
|
# No issues - at least one test provided for production type requiring testing
|
|
@@ -1075,13 +995,29 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
1075
995
|
def get_summary(
|
|
1076
996
|
self,
|
|
1077
997
|
detection_fields: list[str] = [
|
|
1078
|
-
"name",
|
|
998
|
+
"name",
|
|
999
|
+
"type",
|
|
1000
|
+
"status",
|
|
1001
|
+
"test_status",
|
|
1002
|
+
"source",
|
|
1003
|
+
"data_source",
|
|
1004
|
+
"search",
|
|
1005
|
+
"file_path",
|
|
1079
1006
|
],
|
|
1080
1007
|
detection_field_aliases: dict[str, str] = {
|
|
1081
|
-
"status": "production_status",
|
|
1008
|
+
"status": "production_status",
|
|
1009
|
+
"test_status": "status",
|
|
1010
|
+
"source": "source_category",
|
|
1082
1011
|
},
|
|
1083
1012
|
tags_fields: list[str] = ["manual_test"],
|
|
1084
|
-
test_result_fields: list[str] = [
|
|
1013
|
+
test_result_fields: list[str] = [
|
|
1014
|
+
"success",
|
|
1015
|
+
"message",
|
|
1016
|
+
"exception",
|
|
1017
|
+
"status",
|
|
1018
|
+
"duration",
|
|
1019
|
+
"wait_duration",
|
|
1020
|
+
],
|
|
1085
1021
|
test_job_fields: list[str] = ["resultCount", "runDuration"],
|
|
1086
1022
|
) -> dict[str, Any]:
|
|
1087
1023
|
"""
|
|
@@ -1121,7 +1057,7 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
1121
1057
|
# Initialize the dict as a mapping of strings to str/bool
|
|
1122
1058
|
result: dict[str, Union[str, bool]] = {
|
|
1123
1059
|
"name": test.name,
|
|
1124
|
-
"test_type": test.test_type
|
|
1060
|
+
"test_type": test.test_type,
|
|
1125
1061
|
}
|
|
1126
1062
|
|
|
1127
1063
|
# If result is not None, get a summary of the test result w/ the requested fields
|
|
@@ -1138,7 +1074,7 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
1138
1074
|
result["message"] = "NO RESULT - Test not run"
|
|
1139
1075
|
|
|
1140
1076
|
# Add the result to our list
|
|
1141
|
-
summary_dict["tests"].append(result)
|
|
1077
|
+
summary_dict["tests"].append(result) # type: ignore
|
|
1142
1078
|
|
|
1143
1079
|
# Return the summary
|
|
1144
1080
|
|