contentctl 5.0.0a2__py3-none-any.whl → 5.0.1__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 +2 -4
- 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 +83 -53
- 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 +10 -10
- 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 +255 -323
- contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +111 -46
- 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 +47 -35
- contentctl/objects/baseline_tags.py +29 -22
- contentctl/objects/config.py +1 -1
- contentctl/objects/constants.py +32 -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 +53 -31
- 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 +68 -11
- 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 +54 -49
- 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/analyticstories_detections.j2 +1 -1
- contentctl/output/templates/analyticstories_stories.j2 +1 -1
- 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 +2 -8
- contentctl/output/templates/savedsearches_investigations.j2 +2 -2
- contentctl/output/templates/transforms.j2 +2 -4
- contentctl/output/yml_writer.py +18 -24
- contentctl/templates/stories/cobalt_strike.yml +1 -0
- {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/METADATA +1 -1
- contentctl-5.0.1.dist-info/RECORD +168 -0
- contentctl/actions/initialize_old.py +0 -245
- contentctl/objects/observable.py +0 -39
- contentctl-5.0.0a2.dist-info/RECORD +0 -170
- {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/LICENSE.md +0 -0
- {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/WHEEL +0 -0
- {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.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
|
-
|
|
31
|
-
from contentctl.objects.detection_tags import DetectionTags
|
|
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
25
|
|
|
39
|
-
|
|
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,91 +380,24 @@ 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]]:
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
threat_object['threat_object_field'] = entity.field
|
|
381
|
-
threat_object['threat_object_type'] = entity.type
|
|
382
|
-
risk_objects.append(threat_object)
|
|
383
|
-
return risk_objects
|
|
384
|
-
|
|
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
|
|
386
|
+
if self.rba is None:
|
|
387
|
+
raise Exception(
|
|
388
|
+
f"Attempting to serialize rba section of [{self.name}], however RBA section is None"
|
|
389
|
+
)
|
|
390
|
+
"""
|
|
391
|
+
action.risk.param._risk
|
|
392
|
+
of the conf file only contains a list of dicts. We do not eant to
|
|
393
|
+
include the message here, so we do not return it.
|
|
394
|
+
"""
|
|
395
|
+
rba_dict = self.rba.model_dump()
|
|
396
|
+
return rba_dict["risk_objects"] + rba_dict["threat_objects"]
|
|
446
397
|
|
|
447
398
|
@computed_field
|
|
448
399
|
@property
|
|
449
|
-
def metadata(self) -> dict[str, str|float]:
|
|
400
|
+
def metadata(self) -> dict[str, str | float]:
|
|
450
401
|
# NOTE: we ignore the type error around self.status because we are using Pydantic's
|
|
451
402
|
# use_enum_values configuration
|
|
452
403
|
# https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
|
|
@@ -456,10 +407,19 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
456
407
|
# dict below) should not have any impact, but renaming or removing any of these fields will
|
|
457
408
|
# break the `inspect` action.
|
|
458
409
|
return {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
410
|
+
"detection_id": str(self.id),
|
|
411
|
+
"deprecated": "1" if self.status == DetectionStatus.deprecated else "0", # type: ignore
|
|
412
|
+
"detection_version": str(self.version),
|
|
413
|
+
"publish_time": datetime.datetime(
|
|
414
|
+
self.date.year,
|
|
415
|
+
self.date.month,
|
|
416
|
+
self.date.day,
|
|
417
|
+
0,
|
|
418
|
+
0,
|
|
419
|
+
0,
|
|
420
|
+
0,
|
|
421
|
+
tzinfo=datetime.timezone.utc,
|
|
422
|
+
).timestamp(),
|
|
463
423
|
}
|
|
464
424
|
|
|
465
425
|
@model_serializer
|
|
@@ -480,9 +440,9 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
480
440
|
}
|
|
481
441
|
if self.rba is not None:
|
|
482
442
|
model["risk_severity"] = self.rba.severity
|
|
483
|
-
model[
|
|
443
|
+
model["tags"]["risk_score"] = self.rba.risk_score
|
|
484
444
|
else:
|
|
485
|
-
model[
|
|
445
|
+
model["tags"]["risk_score"] = 0
|
|
486
446
|
|
|
487
447
|
# Only a subset of macro fields are required:
|
|
488
448
|
all_macros: list[dict[str, str | list[str]]] = []
|
|
@@ -490,13 +450,13 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
490
450
|
macro_dump: dict[str, str | list[str]] = {
|
|
491
451
|
"name": macro.name,
|
|
492
452
|
"definition": macro.definition,
|
|
493
|
-
"description": macro.description
|
|
453
|
+
"description": macro.description,
|
|
494
454
|
}
|
|
495
455
|
if len(macro.arguments) > 0:
|
|
496
|
-
macro_dump[
|
|
456
|
+
macro_dump["arguments"] = macro.arguments
|
|
497
457
|
|
|
498
458
|
all_macros.append(macro_dump)
|
|
499
|
-
model[
|
|
459
|
+
model["macros"] = all_macros # type: ignore
|
|
500
460
|
|
|
501
461
|
all_lookups: list[dict[str, str | int | None]] = []
|
|
502
462
|
for lookup in self.lookups:
|
|
@@ -507,7 +467,7 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
507
467
|
"description": lookup.description,
|
|
508
468
|
"collection": lookup.collection,
|
|
509
469
|
"case_sensitive_match": None,
|
|
510
|
-
"fields_list": lookup.fields_to_fields_list_conf_format
|
|
470
|
+
"fields_list": lookup.fields_to_fields_list_conf_format,
|
|
511
471
|
}
|
|
512
472
|
)
|
|
513
473
|
elif isinstance(lookup, FileBackedLookup):
|
|
@@ -517,15 +477,17 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
517
477
|
"description": lookup.description,
|
|
518
478
|
"filename": lookup.filename.name,
|
|
519
479
|
"default_match": "true" if lookup.default_match else "false",
|
|
520
|
-
"case_sensitive_match": "true"
|
|
480
|
+
"case_sensitive_match": "true"
|
|
481
|
+
if lookup.case_sensitive_match
|
|
482
|
+
else "false",
|
|
521
483
|
"match_type": lookup.match_type_to_conf_format,
|
|
522
|
-
"min_matches": lookup.min_matches
|
|
484
|
+
"min_matches": lookup.min_matches,
|
|
523
485
|
}
|
|
524
486
|
)
|
|
525
|
-
model[
|
|
487
|
+
model["lookups"] = all_lookups # type: ignore
|
|
526
488
|
|
|
527
489
|
# Combine fields from this model with fields from parent
|
|
528
|
-
super_fields.update(model)
|
|
490
|
+
super_fields.update(model) # type: ignore
|
|
529
491
|
|
|
530
492
|
# return the model
|
|
531
493
|
return super_fields
|
|
@@ -558,7 +520,7 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
558
520
|
updated_data_source_names: set[str] = set()
|
|
559
521
|
|
|
560
522
|
for ds in self.data_source:
|
|
561
|
-
split_data_sources = {d.strip() for d in ds.split(
|
|
523
|
+
split_data_sources = {d.strip() for d in ds.split("AND")}
|
|
562
524
|
updated_data_source_names.update(split_data_sources)
|
|
563
525
|
|
|
564
526
|
sources = sorted(list(updated_data_source_names))
|
|
@@ -567,7 +529,9 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
567
529
|
missing_sources: list[str] = []
|
|
568
530
|
for source in sources:
|
|
569
531
|
try:
|
|
570
|
-
matched_data_sources += DataSource.mapNamesToSecurityContentObjects(
|
|
532
|
+
matched_data_sources += DataSource.mapNamesToSecurityContentObjects(
|
|
533
|
+
[source], director
|
|
534
|
+
)
|
|
571
535
|
except Exception:
|
|
572
536
|
# We gobble this up and add it to a global set so that we
|
|
573
537
|
# can print it ONCE at the end of the build of datasources.
|
|
@@ -584,7 +548,7 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
584
548
|
self.data_source_objects = matched_data_sources
|
|
585
549
|
|
|
586
550
|
for story in self.tags.analytic_story:
|
|
587
|
-
story.detections.append(self)
|
|
551
|
+
story.detections.append(self)
|
|
588
552
|
|
|
589
553
|
self.cve_enrichment_func(__context)
|
|
590
554
|
|
|
@@ -595,32 +559,39 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
595
559
|
# 1 of the drilldowns contains the string Drilldown.SEARCH_PLACEHOLDER.
|
|
596
560
|
# This is presently a requirement when 1 or more drilldowns are added to a detection.
|
|
597
561
|
# Note that this is only required for production searches that are not hunting
|
|
598
|
-
|
|
599
|
-
if
|
|
600
|
-
|
|
562
|
+
|
|
563
|
+
if (
|
|
564
|
+
self.type == AnalyticsType.Hunting
|
|
565
|
+
or self.status != DetectionStatus.production
|
|
566
|
+
):
|
|
567
|
+
# No additional check need to happen on the potential drilldowns.
|
|
601
568
|
pass
|
|
602
569
|
else:
|
|
603
570
|
found_placeholder = False
|
|
604
571
|
if len(self.drilldown_searches) < 2:
|
|
605
|
-
raise ValueError(
|
|
572
|
+
raise ValueError(
|
|
573
|
+
f"This detection is required to have 2 drilldown_searches, but only has [{len(self.drilldown_searches)}]"
|
|
574
|
+
)
|
|
606
575
|
for drilldown in self.drilldown_searches:
|
|
607
576
|
if DRILLDOWN_SEARCH_PLACEHOLDER in drilldown.search:
|
|
608
577
|
found_placeholder = True
|
|
609
578
|
if not found_placeholder:
|
|
610
|
-
raise ValueError(
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
579
|
+
raise ValueError(
|
|
580
|
+
"Detection has one or more drilldown_searches, but none of them "
|
|
581
|
+
f"contained '{DRILLDOWN_SEARCH_PLACEHOLDER}. This is a requirement "
|
|
582
|
+
"if drilldown_searches are defined.'"
|
|
583
|
+
)
|
|
584
|
+
|
|
614
585
|
# Update the search fields with the original search, if required
|
|
615
586
|
for drilldown in self.drilldown_searches:
|
|
616
587
|
drilldown.perform_search_substitutions(self)
|
|
617
588
|
|
|
618
|
-
#For experimental purposes, add the default drilldowns
|
|
619
|
-
#self.drilldown_searches.extend(Drilldown.constructDrilldownsFromDetection(self))
|
|
589
|
+
# For experimental purposes, add the default drilldowns
|
|
590
|
+
# self.drilldown_searches.extend(Drilldown.constructDrilldownsFromDetection(self))
|
|
620
591
|
|
|
621
592
|
@property
|
|
622
|
-
def drilldowns_in_JSON(self) -> list[dict[str,str]]:
|
|
623
|
-
"""This function is required for proper JSON
|
|
593
|
+
def drilldowns_in_JSON(self) -> list[dict[str, str]]:
|
|
594
|
+
"""This function is required for proper JSON
|
|
624
595
|
serializiation of drilldowns to occur in savedsearches.conf.
|
|
625
596
|
It returns the list[Drilldown] as a list[dict].
|
|
626
597
|
Without this function, the jinja template is unable
|
|
@@ -628,24 +599,26 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
628
599
|
|
|
629
600
|
Returns:
|
|
630
601
|
list[dict[str,str]]: List of Drilldowns dumped to dict format
|
|
631
|
-
"""
|
|
602
|
+
"""
|
|
632
603
|
return [drilldown.model_dump() for drilldown in self.drilldown_searches]
|
|
633
604
|
|
|
634
|
-
@field_validator(
|
|
605
|
+
@field_validator("lookups", mode="before")
|
|
635
606
|
@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)
|
|
607
|
+
def getDetectionLookups(cls, v: list[str], info: ValidationInfo) -> list[Lookup]:
|
|
608
|
+
director: DirectorOutputDto = info.context.get("output_dto", None)
|
|
609
|
+
|
|
610
|
+
search: Union[str, None] = info.data.get("search", None)
|
|
640
611
|
if search is None:
|
|
641
612
|
raise ValueError("Search was None - is this file missing the search field?")
|
|
642
|
-
|
|
613
|
+
|
|
643
614
|
lookups = Lookup.get_lookups(search, director)
|
|
644
615
|
return lookups
|
|
645
616
|
|
|
646
|
-
@field_validator(
|
|
617
|
+
@field_validator("baselines", mode="before")
|
|
647
618
|
@classmethod
|
|
648
|
-
def mapDetectionNamesToBaselineObjects(
|
|
619
|
+
def mapDetectionNamesToBaselineObjects(
|
|
620
|
+
cls, v: list[str], info: ValidationInfo
|
|
621
|
+
) -> List[Baseline]:
|
|
649
622
|
if len(v) > 0:
|
|
650
623
|
raise ValueError(
|
|
651
624
|
"Error, baselines are constructed automatically at runtime. Please do not include this field."
|
|
@@ -653,7 +626,9 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
653
626
|
|
|
654
627
|
name: Union[str, None] = info.data.get("name", None)
|
|
655
628
|
if name is None:
|
|
656
|
-
raise ValueError(
|
|
629
|
+
raise ValueError(
|
|
630
|
+
"Error, cannot get Baselines because the Detection does not have a 'name' defined."
|
|
631
|
+
)
|
|
657
632
|
|
|
658
633
|
if info.context is None:
|
|
659
634
|
raise ValueError("ValidationInfo.context unexpectedly null")
|
|
@@ -664,14 +639,16 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
664
639
|
# This matching is a bit strange, because baseline.tags.detections starts as a list of strings, but
|
|
665
640
|
# is eventually updated to a list of Detections as we construct all of the detection objects.
|
|
666
641
|
detection_names = [
|
|
667
|
-
detection_name
|
|
642
|
+
detection_name
|
|
643
|
+
for detection_name in baseline.tags.detections
|
|
644
|
+
if isinstance(detection_name, str)
|
|
668
645
|
]
|
|
669
646
|
if name in detection_names:
|
|
670
647
|
baselines.append(baseline)
|
|
671
648
|
|
|
672
649
|
return baselines
|
|
673
650
|
|
|
674
|
-
@field_validator(
|
|
651
|
+
@field_validator("macros", mode="before")
|
|
675
652
|
@classmethod
|
|
676
653
|
def getDetectionMacros(cls, v: list[str], info: ValidationInfo) -> list[Macro]:
|
|
677
654
|
if info.context is None:
|
|
@@ -687,21 +664,25 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
687
664
|
message = f"Expected 'search_name' to be a string, instead it was [{type(search_name)}]"
|
|
688
665
|
assert isinstance(search_name, str), message
|
|
689
666
|
|
|
690
|
-
filter_macro_name =
|
|
691
|
-
.replace(
|
|
692
|
-
.replace(
|
|
693
|
-
.replace(
|
|
694
|
-
.
|
|
695
|
-
|
|
667
|
+
filter_macro_name = (
|
|
668
|
+
search_name.replace(" ", "_")
|
|
669
|
+
.replace("-", "_")
|
|
670
|
+
.replace(".", "_")
|
|
671
|
+
.replace("/", "_")
|
|
672
|
+
.lower()
|
|
673
|
+
+ "_filter"
|
|
674
|
+
)
|
|
696
675
|
try:
|
|
697
|
-
filter_macro = Macro.mapNamesToSecurityContentObjects(
|
|
676
|
+
filter_macro = Macro.mapNamesToSecurityContentObjects(
|
|
677
|
+
[filter_macro_name], director
|
|
678
|
+
)[0]
|
|
698
679
|
except Exception:
|
|
699
680
|
# Filter macro did not exist, so create one at runtime
|
|
700
681
|
filter_macro = Macro.model_validate(
|
|
701
682
|
{
|
|
702
683
|
"name": filter_macro_name,
|
|
703
|
-
"definition":
|
|
704
|
-
"description":
|
|
684
|
+
"definition": "search *",
|
|
685
|
+
"description": "Update this macro to limit the output results to filter out false positives.",
|
|
705
686
|
}
|
|
706
687
|
)
|
|
707
688
|
director.addContentToDictMappings(filter_macro)
|
|
@@ -724,12 +705,12 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
724
705
|
|
|
725
706
|
@field_validator("enabled_by_default", mode="before")
|
|
726
707
|
def only_enabled_if_production_status(cls, v: Any, info: ValidationInfo) -> bool:
|
|
727
|
-
|
|
708
|
+
"""
|
|
728
709
|
A detection can ONLY be enabled by default if it is a PRODUCTION detection.
|
|
729
710
|
If not (for example, it is EXPERIMENTAL or DEPRECATED) then we will throw an exception.
|
|
730
711
|
Similarly, a detection MUST be schedulable, meaning that it must be Anomaly, Correleation, or TTP.
|
|
731
712
|
We will not allow Hunting searches to be enabled by default.
|
|
732
|
-
|
|
713
|
+
"""
|
|
733
714
|
if v is False:
|
|
734
715
|
return v
|
|
735
716
|
|
|
@@ -740,16 +721,23 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
740
721
|
errors.append(
|
|
741
722
|
f"status is '{status.name}'. Detections that are enabled by default MUST be "
|
|
742
723
|
f"'{DetectionStatus.production}'"
|
|
743
|
-
|
|
724
|
+
)
|
|
744
725
|
|
|
745
|
-
if searchType not in [
|
|
726
|
+
if searchType not in [
|
|
727
|
+
AnalyticsType.Anomaly,
|
|
728
|
+
AnalyticsType.Correlation,
|
|
729
|
+
AnalyticsType.TTP,
|
|
730
|
+
]:
|
|
746
731
|
errors.append(
|
|
747
732
|
f"type is '{searchType}'. Detections that are enabled by default MUST be one"
|
|
748
733
|
" of the following types: "
|
|
749
|
-
f"{[AnalyticsType.Anomaly, AnalyticsType.Correlation, AnalyticsType.TTP]}"
|
|
734
|
+
f"{[AnalyticsType.Anomaly, AnalyticsType.Correlation, AnalyticsType.TTP]}"
|
|
735
|
+
)
|
|
750
736
|
if len(errors) > 0:
|
|
751
737
|
error_message = "\n - ".join(errors)
|
|
752
|
-
raise ValueError(
|
|
738
|
+
raise ValueError(
|
|
739
|
+
f"Detection is 'enabled_by_default: true' however \n - {error_message}"
|
|
740
|
+
)
|
|
753
741
|
|
|
754
742
|
return v
|
|
755
743
|
|
|
@@ -760,39 +748,42 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
760
748
|
else:
|
|
761
749
|
self.tags.nist = [NistCategory.DE_AE]
|
|
762
750
|
return self
|
|
763
|
-
|
|
764
751
|
|
|
765
752
|
@model_validator(mode="after")
|
|
766
753
|
def ensureThrottlingFieldsExist(self):
|
|
767
|
-
|
|
754
|
+
"""
|
|
768
755
|
For throttling to work properly, the fields to throttle on MUST
|
|
769
756
|
exist in the search itself. If not, then we cannot apply the throttling
|
|
770
|
-
|
|
757
|
+
"""
|
|
771
758
|
if self.tags.throttling is None:
|
|
772
759
|
# No throttling configured for this detection
|
|
773
760
|
return self
|
|
774
761
|
|
|
775
|
-
missing_fields:list[str] = [
|
|
762
|
+
missing_fields: list[str] = [
|
|
763
|
+
field for field in self.tags.throttling.fields if field not in self.search
|
|
764
|
+
]
|
|
776
765
|
if len(missing_fields) > 0:
|
|
777
|
-
raise ValueError(
|
|
766
|
+
raise ValueError(
|
|
767
|
+
f"The following throttle fields were missing from the search: {missing_fields}"
|
|
768
|
+
)
|
|
778
769
|
|
|
779
770
|
else:
|
|
780
771
|
# All throttling fields present in search
|
|
781
772
|
return self
|
|
782
|
-
|
|
783
|
-
|
|
784
773
|
|
|
785
774
|
@model_validator(mode="after")
|
|
786
775
|
def ensureProperRBAConfig(self):
|
|
787
776
|
"""
|
|
788
777
|
If a detection has an RBA deployment and is PRODUCTION, then it must have an RBA config, with at least one risk object
|
|
789
|
-
|
|
778
|
+
|
|
790
779
|
Returns:
|
|
791
780
|
self: Returns itself if the validation passes
|
|
792
781
|
"""
|
|
793
782
|
|
|
794
|
-
|
|
795
|
-
|
|
783
|
+
if (
|
|
784
|
+
self.deployment.alert_action.rba is None
|
|
785
|
+
or self.deployment.alert_action.rba.enabled is False
|
|
786
|
+
):
|
|
796
787
|
# confirm we don't have an RBA config
|
|
797
788
|
if self.rba is None:
|
|
798
789
|
return self
|
|
@@ -806,55 +797,23 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
806
797
|
"Detection is expected to have an RBA object based on its deployment config"
|
|
807
798
|
)
|
|
808
799
|
else:
|
|
809
|
-
if len(self.rba.risk_objects) > 0:
|
|
800
|
+
if len(self.rba.risk_objects) > 0: # type: ignore
|
|
810
801
|
return self
|
|
811
802
|
else:
|
|
812
803
|
raise ValueError(
|
|
813
804
|
"Detection expects an RBA config with at least one risk object."
|
|
814
805
|
)
|
|
815
806
|
|
|
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
807
|
@model_validator(mode="after")
|
|
852
808
|
def search_rba_fields_exist_validate(self):
|
|
853
809
|
# Return immediately if RBA isn't required
|
|
854
|
-
if (
|
|
810
|
+
if (
|
|
811
|
+
self.deployment.alert_action.rba.enabled is False
|
|
812
|
+
or self.deployment.alert_action.rba is None
|
|
813
|
+
) and self.rba is None: # type: ignore
|
|
855
814
|
return self
|
|
856
|
-
|
|
857
|
-
# Raise error if RBA isn't present
|
|
815
|
+
|
|
816
|
+
# Raise error if RBA isn't present
|
|
858
817
|
if self.rba is None:
|
|
859
818
|
raise ValueError(
|
|
860
819
|
"RBA is required for this detection based on its deployment config"
|
|
@@ -869,7 +828,9 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
869
828
|
if self.rba.message:
|
|
870
829
|
matches = re.findall(field_match_regex, self.rba.message.lower())
|
|
871
830
|
message_fields = [match.replace("$", "").lower() for match in matches]
|
|
872
|
-
missing_fields = set(
|
|
831
|
+
missing_fields = set(
|
|
832
|
+
[field for field in rba_fields if field not in self.search.lower()]
|
|
833
|
+
)
|
|
873
834
|
else:
|
|
874
835
|
message_fields = []
|
|
875
836
|
missing_fields = set()
|
|
@@ -880,15 +841,16 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
880
841
|
"The following fields are declared in the rba config, but do not exist in the "
|
|
881
842
|
f"search: {missing_fields}"
|
|
882
843
|
)
|
|
883
|
-
missing_fields = set(
|
|
844
|
+
missing_fields = set(
|
|
845
|
+
[field for field in message_fields if field not in self.search.lower()]
|
|
846
|
+
)
|
|
884
847
|
if len(missing_fields) > 0:
|
|
885
848
|
error_messages.append(
|
|
886
849
|
"The following fields are used as fields in the message, but do not exist in "
|
|
887
850
|
f"the search: {missing_fields}"
|
|
888
851
|
)
|
|
889
852
|
|
|
890
|
-
if len(error_messages) > 0 and self.status == DetectionStatus.production:
|
|
891
|
-
|
|
853
|
+
if len(error_messages) > 0 and self.status == DetectionStatus.production:
|
|
892
854
|
msg = (
|
|
893
855
|
"Use of fields in rba/messages that do not appear in search:\n\t- "
|
|
894
856
|
"\n\t- ".join(error_messages)
|
|
@@ -896,52 +858,8 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
896
858
|
raise ValueError(msg)
|
|
897
859
|
return self
|
|
898
860
|
|
|
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
861
|
@field_validator("tests", mode="before")
|
|
944
|
-
def ensure_yml_test_is_unittest(cls, v:list[dict]):
|
|
862
|
+
def ensure_yml_test_is_unittest(cls, v: list[dict]):
|
|
945
863
|
"""The typing for the tests field allows it to be one of
|
|
946
864
|
a number of different types of tests. However, ONLY
|
|
947
865
|
UnitTest should be allowed to be defined in the YML
|
|
@@ -957,17 +875,17 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
957
875
|
it into a different type of test
|
|
958
876
|
|
|
959
877
|
Args:
|
|
960
|
-
v (list[dict]): list of dicts read from the yml.
|
|
878
|
+
v (list[dict]): list of dicts read from the yml.
|
|
961
879
|
Each one SHOULD be a valid UnitTest. If we cannot
|
|
962
880
|
construct a valid unitTest from it, a ValueError should be raised
|
|
963
881
|
|
|
964
882
|
Returns:
|
|
965
|
-
_type_: The input of the function, assuming no
|
|
883
|
+
_type_: The input of the function, assuming no
|
|
966
884
|
ValueError is raised.
|
|
967
|
-
"""
|
|
968
|
-
valueErrors:list[ValueError] = []
|
|
885
|
+
"""
|
|
886
|
+
valueErrors: list[ValueError] = []
|
|
969
887
|
for unitTest in v:
|
|
970
|
-
#This raises a ValueError on a failed UnitTest.
|
|
888
|
+
# This raises a ValueError on a failed UnitTest.
|
|
971
889
|
try:
|
|
972
890
|
UnitTest.model_validate(unitTest)
|
|
973
891
|
except ValueError as e:
|
|
@@ -977,13 +895,10 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
977
895
|
# All of these can be constructred as UnitTests with no
|
|
978
896
|
# Exceptions, so let the normal flow continue
|
|
979
897
|
return v
|
|
980
|
-
|
|
981
898
|
|
|
982
899
|
@field_validator("tests")
|
|
983
900
|
def tests_validate(
|
|
984
|
-
cls,
|
|
985
|
-
v: list[UnitTest | IntegrationTest | ManualTest],
|
|
986
|
-
info: ValidationInfo
|
|
901
|
+
cls, v: list[UnitTest | IntegrationTest | ManualTest], info: ValidationInfo
|
|
987
902
|
) -> list[UnitTest | IntegrationTest | ManualTest]:
|
|
988
903
|
# Only production analytics require tests
|
|
989
904
|
if info.data.get("status", "") != DetectionStatus.production:
|
|
@@ -1003,7 +918,8 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
1003
918
|
# Ensure that there is at least 1 test
|
|
1004
919
|
if len(v) == 0:
|
|
1005
920
|
raise ValueError(
|
|
1006
|
-
"At least one test is REQUIRED for production detection: "
|
|
921
|
+
"At least one test is REQUIRED for production detection: "
|
|
922
|
+
+ info.data.get("name", "NO NAME FOUND")
|
|
1007
923
|
)
|
|
1008
924
|
|
|
1009
925
|
# No issues - at least one test provided for production type requiring testing
|
|
@@ -1075,13 +991,29 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
1075
991
|
def get_summary(
|
|
1076
992
|
self,
|
|
1077
993
|
detection_fields: list[str] = [
|
|
1078
|
-
"name",
|
|
994
|
+
"name",
|
|
995
|
+
"type",
|
|
996
|
+
"status",
|
|
997
|
+
"test_status",
|
|
998
|
+
"source",
|
|
999
|
+
"data_source",
|
|
1000
|
+
"search",
|
|
1001
|
+
"file_path",
|
|
1079
1002
|
],
|
|
1080
1003
|
detection_field_aliases: dict[str, str] = {
|
|
1081
|
-
"status": "production_status",
|
|
1004
|
+
"status": "production_status",
|
|
1005
|
+
"test_status": "status",
|
|
1006
|
+
"source": "source_category",
|
|
1082
1007
|
},
|
|
1083
1008
|
tags_fields: list[str] = ["manual_test"],
|
|
1084
|
-
test_result_fields: list[str] = [
|
|
1009
|
+
test_result_fields: list[str] = [
|
|
1010
|
+
"success",
|
|
1011
|
+
"message",
|
|
1012
|
+
"exception",
|
|
1013
|
+
"status",
|
|
1014
|
+
"duration",
|
|
1015
|
+
"wait_duration",
|
|
1016
|
+
],
|
|
1085
1017
|
test_job_fields: list[str] = ["resultCount", "runDuration"],
|
|
1086
1018
|
) -> dict[str, Any]:
|
|
1087
1019
|
"""
|
|
@@ -1121,7 +1053,7 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
1121
1053
|
# Initialize the dict as a mapping of strings to str/bool
|
|
1122
1054
|
result: dict[str, Union[str, bool]] = {
|
|
1123
1055
|
"name": test.name,
|
|
1124
|
-
"test_type": test.test_type
|
|
1056
|
+
"test_type": test.test_type,
|
|
1125
1057
|
}
|
|
1126
1058
|
|
|
1127
1059
|
# If result is not None, get a summary of the test result w/ the requested fields
|
|
@@ -1138,7 +1070,7 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
1138
1070
|
result["message"] = "NO RESULT - Test not run"
|
|
1139
1071
|
|
|
1140
1072
|
# Add the result to our list
|
|
1141
|
-
summary_dict["tests"].append(result)
|
|
1073
|
+
summary_dict["tests"].append(result) # type: ignore
|
|
1142
1074
|
|
|
1143
1075
|
# Return the summary
|
|
1144
1076
|
|