contentctl 4.4.7__py3-none-any.whl → 5.0.0a2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- contentctl/actions/build.py +39 -27
- contentctl/actions/detection_testing/DetectionTestingManager.py +0 -1
- contentctl/actions/detection_testing/GitService.py +132 -72
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +32 -26
- contentctl/actions/detection_testing/progress_bar.py +6 -6
- contentctl/actions/detection_testing/views/DetectionTestingView.py +4 -4
- contentctl/actions/new_content.py +98 -81
- contentctl/actions/test.py +4 -5
- contentctl/actions/validate.py +2 -1
- contentctl/contentctl.py +114 -80
- contentctl/helper/utils.py +0 -14
- contentctl/input/director.py +5 -5
- contentctl/input/new_content_questions.py +2 -2
- contentctl/input/yml_reader.py +11 -6
- contentctl/objects/abstract_security_content_objects/detection_abstract.py +228 -120
- contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +5 -7
- contentctl/objects/alert_action.py +2 -1
- contentctl/objects/atomic.py +1 -0
- contentctl/objects/base_test.py +4 -3
- contentctl/objects/base_test_result.py +3 -3
- contentctl/objects/baseline.py +26 -6
- contentctl/objects/baseline_tags.py +2 -3
- contentctl/objects/config.py +789 -596
- contentctl/objects/constants.py +4 -1
- contentctl/objects/correlation_search.py +89 -95
- contentctl/objects/data_source.py +5 -6
- contentctl/objects/deployment.py +2 -10
- contentctl/objects/deployment_email.py +2 -1
- contentctl/objects/deployment_notable.py +2 -1
- contentctl/objects/deployment_phantom.py +2 -1
- contentctl/objects/deployment_rba.py +2 -1
- contentctl/objects/deployment_scheduling.py +2 -1
- contentctl/objects/deployment_slack.py +2 -1
- contentctl/objects/detection_tags.py +7 -42
- contentctl/objects/drilldown.py +1 -0
- contentctl/objects/enums.py +21 -58
- contentctl/objects/investigation.py +6 -5
- contentctl/objects/investigation_tags.py +2 -3
- contentctl/objects/lookup.py +145 -63
- contentctl/objects/macro.py +2 -3
- contentctl/objects/mitre_attack_enrichment.py +2 -2
- contentctl/objects/observable.py +3 -1
- contentctl/objects/playbook_tags.py +5 -1
- contentctl/objects/rba.py +90 -0
- contentctl/objects/risk_event.py +87 -144
- contentctl/objects/story_tags.py +1 -2
- contentctl/objects/test_attack_data.py +2 -1
- contentctl/objects/unit_test_baseline.py +2 -1
- contentctl/output/api_json_output.py +233 -220
- contentctl/output/conf_output.py +51 -44
- contentctl/output/conf_writer.py +201 -125
- contentctl/output/data_source_writer.py +0 -1
- contentctl/output/json_writer.py +2 -4
- contentctl/output/svg_output.py +1 -1
- contentctl/output/templates/analyticstories_detections.j2 +1 -1
- contentctl/output/templates/collections.j2 +1 -1
- contentctl/output/templates/doc_detections.j2 +0 -5
- contentctl/output/templates/savedsearches_detections.j2 +8 -3
- contentctl/output/templates/transforms.j2 +4 -4
- contentctl/output/yml_writer.py +15 -0
- contentctl/templates/detections/endpoint/anomalous_usage_of_7zip.yml +16 -34
- {contentctl-4.4.7.dist-info → contentctl-5.0.0a2.dist-info}/METADATA +5 -4
- {contentctl-4.4.7.dist-info → contentctl-5.0.0a2.dist-info}/RECORD +66 -69
- {contentctl-4.4.7.dist-info → contentctl-5.0.0a2.dist-info}/WHEEL +1 -1
- contentctl/objects/event_source.py +0 -11
- contentctl/output/detection_writer.py +0 -28
- contentctl/output/new_content_yml_output.py +0 -56
- contentctl/output/yml_output.py +0 -66
- {contentctl-4.4.7.dist-info → contentctl-5.0.0a2.dist-info}/LICENSE.md +0 -0
- {contentctl-4.4.7.dist-info → contentctl-5.0.0a2.dist-info}/entry_points.txt +0 -0
|
@@ -83,9 +83,9 @@ class MitreAttackGroup(BaseModel):
|
|
|
83
83
|
return []
|
|
84
84
|
return contributors
|
|
85
85
|
|
|
86
|
-
# TODO (#266): disable the use_enum_values configuration
|
|
87
86
|
class MitreAttackEnrichment(BaseModel):
|
|
88
|
-
|
|
87
|
+
|
|
88
|
+
ConfigDict(extra='forbid')
|
|
89
89
|
mitre_attack_id: MITRE_ATTACK_ID_TYPE = Field(...)
|
|
90
90
|
mitre_attack_technique: str = Field(...)
|
|
91
91
|
mitre_attack_tactics: List[MitreTactics] = Field(...)
|
contentctl/objects/observable.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
from pydantic import BaseModel, field_validator
|
|
1
|
+
from pydantic import BaseModel, field_validator, ConfigDict
|
|
2
2
|
from contentctl.objects.constants import SES_OBSERVABLE_TYPE_MAPPING, RBA_OBSERVABLE_ROLE_MAPPING
|
|
3
3
|
|
|
4
|
+
# TODO (cmcginley): should this class be removed?
|
|
4
5
|
|
|
5
6
|
class Observable(BaseModel):
|
|
7
|
+
model_config = ConfigDict(extra="forbid")
|
|
6
8
|
name: str
|
|
7
9
|
type: str
|
|
8
10
|
role: list[str]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
from typing import TYPE_CHECKING, Optional, List
|
|
3
|
-
from pydantic import BaseModel, Field
|
|
3
|
+
from pydantic import BaseModel, Field,ConfigDict
|
|
4
4
|
import enum
|
|
5
5
|
from contentctl.objects.detection import Detection
|
|
6
6
|
|
|
@@ -36,6 +36,7 @@ class DefendTechnique(str,enum.Enum):
|
|
|
36
36
|
D3_SRA = "D3-SRA"
|
|
37
37
|
D3_RUAA = "D3-RUAA"
|
|
38
38
|
class PlaybookTag(BaseModel):
|
|
39
|
+
model_config = ConfigDict(extra="forbid")
|
|
39
40
|
analytic_story: Optional[list] = None
|
|
40
41
|
detections: Optional[list] = None
|
|
41
42
|
platform_tags: list[str] = Field(...,min_length=0)
|
|
@@ -46,5 +47,8 @@ class PlaybookTag(BaseModel):
|
|
|
46
47
|
use_cases: list[PlaybookUseCase] = Field([],min_length=0)
|
|
47
48
|
defend_technique_id: Optional[List[DefendTechnique]] = None
|
|
48
49
|
|
|
50
|
+
labels:list[str] = []
|
|
51
|
+
playbook_outputs:list[str] = []
|
|
52
|
+
|
|
49
53
|
detection_objects: list[Detection] = []
|
|
50
54
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from enum import Enum
|
|
2
|
+
from pydantic import BaseModel, computed_field, Field
|
|
3
|
+
from abc import ABC
|
|
4
|
+
from typing import Set, Annotated
|
|
5
|
+
from contentctl.objects.enums import RiskSeverity
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
RiskScoreValue_Type = Annotated[int, Field(ge=1, le=100)]
|
|
9
|
+
|
|
10
|
+
class RiskObjectType(str, Enum):
|
|
11
|
+
SYSTEM = "system"
|
|
12
|
+
USER = "user"
|
|
13
|
+
OTHER = "other"
|
|
14
|
+
|
|
15
|
+
class ThreatObjectType(str, Enum):
|
|
16
|
+
CERTIFICATE_COMMON_NAME = "certificate_common_name"
|
|
17
|
+
CERTIFICATE_ORGANIZATION = "certificate_organization"
|
|
18
|
+
CERTIFICATE_SERIAL = "certificate_serial"
|
|
19
|
+
CERTIFICATE_UNIT = "certificate_unit"
|
|
20
|
+
COMMAND = "command"
|
|
21
|
+
DOMAIN = "domain"
|
|
22
|
+
EMAIL_ADDRESS = "email_address"
|
|
23
|
+
EMAIL_SUBJECT = "email_subject"
|
|
24
|
+
FILE_HASH = "file_hash"
|
|
25
|
+
FILE_NAME = "file_name"
|
|
26
|
+
FILE_PATH = "file_path"
|
|
27
|
+
HTTP_USER_AGENT = "http_user_agent"
|
|
28
|
+
IP_ADDRESS = "ip_address"
|
|
29
|
+
PROCESS = "process"
|
|
30
|
+
PROCESS_NAME = "process_name"
|
|
31
|
+
PARENT_PROCESS = "parent_process"
|
|
32
|
+
PARENT_PROCESS_NAME = "parent_process_name"
|
|
33
|
+
PROCESS_HASH = "process_hash"
|
|
34
|
+
REGISTRY_PATH = "registry_path"
|
|
35
|
+
REGISTRY_VALUE_NAME = "registry_value_name"
|
|
36
|
+
REGISTRY_VALUE_TEXT = "registry_value_text"
|
|
37
|
+
SERVICE = "service"
|
|
38
|
+
SIGNATURE = "signature"
|
|
39
|
+
SYSTEM = "system"
|
|
40
|
+
TLS_HASH = "tls_hash"
|
|
41
|
+
URL = "url"
|
|
42
|
+
|
|
43
|
+
class RiskObject(BaseModel):
|
|
44
|
+
field: str
|
|
45
|
+
type: RiskObjectType
|
|
46
|
+
score: RiskScoreValue_Type
|
|
47
|
+
|
|
48
|
+
def __hash__(self):
|
|
49
|
+
return hash((self.field, self.type, self.score))
|
|
50
|
+
|
|
51
|
+
class ThreatObject(BaseModel):
|
|
52
|
+
field: str
|
|
53
|
+
type: ThreatObjectType
|
|
54
|
+
|
|
55
|
+
def __hash__(self):
|
|
56
|
+
return hash((self.field, self.type))
|
|
57
|
+
|
|
58
|
+
class RBAObject(BaseModel, ABC):
|
|
59
|
+
message: str
|
|
60
|
+
risk_objects: Annotated[Set[RiskObject], Field(min_length=1)]
|
|
61
|
+
threat_objects: Set[ThreatObject]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@computed_field
|
|
66
|
+
@property
|
|
67
|
+
def risk_score(self)->RiskScoreValue_Type:
|
|
68
|
+
# First get the maximum score associated with
|
|
69
|
+
# a risk object. If there are no objects, then
|
|
70
|
+
# we should throw an exception.
|
|
71
|
+
if len(self.risk_objects) == 0:
|
|
72
|
+
raise Exception("There must be at least one Risk Object present to get Severity.")
|
|
73
|
+
return max([risk_object.score for risk_object in self.risk_objects])
|
|
74
|
+
|
|
75
|
+
@computed_field
|
|
76
|
+
@property
|
|
77
|
+
def severity(self)->RiskSeverity:
|
|
78
|
+
if 0 <= self.risk_score <= 20:
|
|
79
|
+
return RiskSeverity.INFORMATIONAL
|
|
80
|
+
elif 20 < self.risk_score <= 40:
|
|
81
|
+
return RiskSeverity.LOW
|
|
82
|
+
elif 40 < self.risk_score <= 60:
|
|
83
|
+
return RiskSeverity.MEDIUM
|
|
84
|
+
elif 60 < self.risk_score <= 80:
|
|
85
|
+
return RiskSeverity.HIGH
|
|
86
|
+
elif 80 < self.risk_score <= 100:
|
|
87
|
+
return RiskSeverity.CRITICAL
|
|
88
|
+
else:
|
|
89
|
+
raise Exception(f"Error getting severity - risk_score must be between 0-100, but was actually {self.risk_score}")
|
|
90
|
+
|
contentctl/objects/risk_event.py
CHANGED
|
@@ -4,48 +4,7 @@ from functools import cached_property
|
|
|
4
4
|
from pydantic import ConfigDict, BaseModel, Field, PrivateAttr, field_validator, computed_field
|
|
5
5
|
from contentctl.objects.errors import ValidationFailed
|
|
6
6
|
from contentctl.objects.detection import Detection
|
|
7
|
-
from contentctl.objects.
|
|
8
|
-
|
|
9
|
-
# TODO (#259): Map our observable types to more than user/system
|
|
10
|
-
# TODO (#247): centralize this mapping w/ usage of SES_OBSERVABLE_TYPE_MAPPING (see
|
|
11
|
-
# observable.py) and the ad hoc mapping made in detection_abstract.py (see the risk property func)
|
|
12
|
-
TYPE_MAP: dict[str, list[str]] = {
|
|
13
|
-
"system": [
|
|
14
|
-
"Hostname",
|
|
15
|
-
"IP Address",
|
|
16
|
-
"Endpoint"
|
|
17
|
-
],
|
|
18
|
-
"user": [
|
|
19
|
-
"User",
|
|
20
|
-
"User Name",
|
|
21
|
-
"Email Address",
|
|
22
|
-
"Email"
|
|
23
|
-
],
|
|
24
|
-
"hash_values": [],
|
|
25
|
-
"network_artifacts": [],
|
|
26
|
-
"host_artifacts": [],
|
|
27
|
-
"tools": [],
|
|
28
|
-
"other": [
|
|
29
|
-
"Process",
|
|
30
|
-
"URL String",
|
|
31
|
-
"Unknown",
|
|
32
|
-
"Process Name",
|
|
33
|
-
"MAC Address",
|
|
34
|
-
"File Name",
|
|
35
|
-
"File Hash",
|
|
36
|
-
"Resource UID",
|
|
37
|
-
"Uniform Resource Locator",
|
|
38
|
-
"File",
|
|
39
|
-
"Geo Location",
|
|
40
|
-
"Container",
|
|
41
|
-
"Registry Key",
|
|
42
|
-
"Registry Value",
|
|
43
|
-
"Other"
|
|
44
|
-
]
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
# Roles that should not generate risks
|
|
48
|
-
IGNORE_ROLES: list[str] = ["Attacker"]
|
|
7
|
+
from contentctl.objects.rba import RiskObject
|
|
49
8
|
|
|
50
9
|
|
|
51
10
|
class RiskEvent(BaseModel):
|
|
@@ -55,10 +14,12 @@ class RiskEvent(BaseModel):
|
|
|
55
14
|
search_name: str
|
|
56
15
|
|
|
57
16
|
# The subject of the risk event (e.g. a username, process name, system name, account ID, etc.)
|
|
58
|
-
|
|
17
|
+
# (not to be confused w/ the risk object from the detection)
|
|
18
|
+
es_risk_object: int | str
|
|
59
19
|
|
|
60
|
-
# The type of the risk object (e.g. user, system, or other)
|
|
61
|
-
|
|
20
|
+
# The type of the risk object from ES (e.g. user, system, or other) (not to be confused w/
|
|
21
|
+
# the risk object from the detection)
|
|
22
|
+
es_risk_object_type: str
|
|
62
23
|
|
|
63
24
|
# The level of risk associated w/ the risk event
|
|
64
25
|
risk_score: int
|
|
@@ -79,11 +40,11 @@ class RiskEvent(BaseModel):
|
|
|
79
40
|
)
|
|
80
41
|
|
|
81
42
|
# Contributing events search query (we use this to derive the corresponding field from the
|
|
82
|
-
#
|
|
43
|
+
# detection's risk object definition)
|
|
83
44
|
contributing_events_search: str
|
|
84
45
|
|
|
85
|
-
# Private attribute caching the
|
|
86
|
-
|
|
46
|
+
# Private attribute caching the risk object this RiskEvent is mapped to
|
|
47
|
+
_matched_risk_object: RiskObject | None = PrivateAttr(default=None)
|
|
87
48
|
|
|
88
49
|
# Allowing fields that aren't explicitly defined to be passed since some of the risk event's
|
|
89
50
|
# fields vary depending on the SPL which generated them
|
|
@@ -108,7 +69,7 @@ class RiskEvent(BaseModel):
|
|
|
108
69
|
def source_field_name(self) -> str:
|
|
109
70
|
"""
|
|
110
71
|
A cached derivation of the source field name the risk event corresponds to in the relevant
|
|
111
|
-
event(s). Useful for mapping back to
|
|
72
|
+
event(s). Useful for mapping back to a risk object in the detection.
|
|
112
73
|
"""
|
|
113
74
|
pattern = re.compile(
|
|
114
75
|
r"\| savedsearch \"" + self.search_name + r"\" \| search (?P<field>[^=]+)=.+"
|
|
@@ -128,13 +89,6 @@ class RiskEvent(BaseModel):
|
|
|
128
89
|
:param detection: the detection associated w/ this risk event
|
|
129
90
|
:raises: ValidationFailed
|
|
130
91
|
"""
|
|
131
|
-
# Check risk_score
|
|
132
|
-
if self.risk_score != detection.tags.risk_score:
|
|
133
|
-
raise ValidationFailed(
|
|
134
|
-
f"Risk score observed in risk event ({self.risk_score}) does not match risk score in "
|
|
135
|
-
f"detection ({detection.tags.risk_score})."
|
|
136
|
-
)
|
|
137
|
-
|
|
138
92
|
# Check analyticstories
|
|
139
93
|
self.validate_analyticstories(detection)
|
|
140
94
|
|
|
@@ -151,8 +105,15 @@ class RiskEvent(BaseModel):
|
|
|
151
105
|
# Check risk_message
|
|
152
106
|
self.validate_risk_message(detection)
|
|
153
107
|
|
|
154
|
-
#
|
|
155
|
-
|
|
108
|
+
# Ensure the rba object is defined
|
|
109
|
+
if detection.rba is None:
|
|
110
|
+
raise ValidationFailed(
|
|
111
|
+
f"Unexpected error: Detection '{detection.name}' has no RBA objects associated "
|
|
112
|
+
"with it; cannot validate."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
# Check several conditions against the detection's risk objects
|
|
116
|
+
self.validate_risk_against_risk_objects(detection.rba.risk_objects)
|
|
156
117
|
|
|
157
118
|
def validate_mitre_ids(self, detection: Detection) -> None:
|
|
158
119
|
"""
|
|
@@ -177,7 +138,7 @@ class RiskEvent(BaseModel):
|
|
|
177
138
|
if sorted(self.analyticstories) != sorted(detection_analytic_story):
|
|
178
139
|
raise ValidationFailed(
|
|
179
140
|
f"Analytic stories in risk event ({self.analyticstories}) do not match those"
|
|
180
|
-
f" in detection ({detection.tags.analytic_story})."
|
|
141
|
+
f" in detection ({[x.name for x in detection.tags.analytic_story]})."
|
|
181
142
|
)
|
|
182
143
|
|
|
183
144
|
def validate_risk_message(self, detection: Detection) -> None:
|
|
@@ -186,10 +147,20 @@ class RiskEvent(BaseModel):
|
|
|
186
147
|
:param detection: the detection associated w/ this risk event
|
|
187
148
|
:raises: ValidationFailed
|
|
188
149
|
"""
|
|
150
|
+
# Ensure the rba object is defined
|
|
151
|
+
if detection.rba is None:
|
|
152
|
+
raise ValidationFailed(
|
|
153
|
+
f"Unexpected error: Detection '{detection.name}' has no RBA objects associated "
|
|
154
|
+
"with it; cannot validate."
|
|
155
|
+
)
|
|
156
|
+
|
|
189
157
|
# Extract the field replacement tokens ("$...$")
|
|
190
158
|
field_replacement_pattern = re.compile(r"\$\S+\$")
|
|
191
|
-
tokens = field_replacement_pattern.findall(detection.
|
|
159
|
+
tokens = field_replacement_pattern.findall(detection.rba.message)
|
|
192
160
|
|
|
161
|
+
# TODO (#346): could expand this to get the field values from the raw events and check
|
|
162
|
+
# to see that allexpected strings ARE in the risk message (as opposed to checking only
|
|
163
|
+
# that unexpected strings aren't)
|
|
193
164
|
# Check for the presence of each token in the message from the risk event
|
|
194
165
|
for token in tokens:
|
|
195
166
|
if token in self.risk_message:
|
|
@@ -205,7 +176,7 @@ class RiskEvent(BaseModel):
|
|
|
205
176
|
escaped_source_message_with_placeholder: str = re.escape(
|
|
206
177
|
field_replacement_pattern.sub(
|
|
207
178
|
tmp_placeholder,
|
|
208
|
-
detection.
|
|
179
|
+
detection.rba.message
|
|
209
180
|
)
|
|
210
181
|
)
|
|
211
182
|
placeholder_replacement_pattern = re.compile(tmp_placeholder)
|
|
@@ -221,114 +192,86 @@ class RiskEvent(BaseModel):
|
|
|
221
192
|
raise ValidationFailed(
|
|
222
193
|
"Risk message in event does not match the pattern set by the detection. Message in "
|
|
223
194
|
f"risk event: \"{self.risk_message}\". Message in detection: "
|
|
224
|
-
f"\"{detection.
|
|
195
|
+
f"\"{detection.rba.message}\"."
|
|
225
196
|
)
|
|
226
197
|
|
|
227
|
-
def
|
|
198
|
+
def validate_risk_against_risk_objects(self, risk_objects: set[RiskObject]) -> None:
|
|
228
199
|
"""
|
|
229
|
-
Given the
|
|
230
|
-
|
|
231
|
-
:param
|
|
200
|
+
Given the risk objects from the associated detection, validate the risk event against those
|
|
201
|
+
risk objects
|
|
202
|
+
:param risk_objects: the risk objects from the detection
|
|
232
203
|
:raises: ValidationFailed
|
|
233
204
|
"""
|
|
234
|
-
# Get the matched
|
|
235
|
-
# risk is missing values associated w/
|
|
236
|
-
|
|
205
|
+
# Get the matched risk object; will raise validation errors if no match can be made or if
|
|
206
|
+
# risk is missing values associated w/ risk objects
|
|
207
|
+
matched_risk_object = self.get_matched_risk_object(risk_objects)
|
|
237
208
|
|
|
238
|
-
# The risk object type should match our mapping of
|
|
239
|
-
|
|
240
|
-
if self.
|
|
209
|
+
# The risk object type from the risk event should match our mapping of internal risk object
|
|
210
|
+
# types
|
|
211
|
+
if self.es_risk_object_type != matched_risk_object.type.value:
|
|
241
212
|
raise ValidationFailed(
|
|
242
|
-
f"The risk object type ({self.
|
|
243
|
-
|
|
244
|
-
f"
|
|
245
|
-
f"source_field_name={self.source_field_name}), "
|
|
246
|
-
f"
|
|
247
|
-
f"
|
|
213
|
+
f"The risk object type from the risk event ({self.es_risk_object_type}) does not match"
|
|
214
|
+
" the expected type based on the matched risk object "
|
|
215
|
+
f"({matched_risk_object.type.value}): risk event=(object={self.es_risk_object}, "
|
|
216
|
+
f"type={self.es_risk_object_type}, source_field_name={self.source_field_name}), "
|
|
217
|
+
f"risk object=(name={matched_risk_object.field}, "
|
|
218
|
+
f"type={matched_risk_object.type.value})"
|
|
248
219
|
)
|
|
249
220
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
:returns: a string (the risk object type)
|
|
257
|
-
:raises ValueError: if the observable type has not yet been mapped to a risk object type
|
|
258
|
-
"""
|
|
259
|
-
# Iterate over the map and search the lists for a match
|
|
260
|
-
for risk_type in TYPE_MAP:
|
|
261
|
-
if observable_type in TYPE_MAP[risk_type]:
|
|
262
|
-
return risk_type
|
|
263
|
-
|
|
264
|
-
raise ValueError(
|
|
265
|
-
f"Observable type {observable_type} does not have a mapping to a risk type in TYPE_MAP"
|
|
266
|
-
)
|
|
221
|
+
# Check risk_score
|
|
222
|
+
if self.risk_score != matched_risk_object.score:
|
|
223
|
+
raise ValidationFailed(
|
|
224
|
+
f"Risk score observed in risk event ({self.risk_score}) does not match risk score in "
|
|
225
|
+
f"matched risk object from detection ({matched_risk_object.score})."
|
|
226
|
+
)
|
|
267
227
|
|
|
268
|
-
|
|
269
|
-
def ignore_observable(observable: Observable) -> bool:
|
|
270
|
-
"""
|
|
271
|
-
Given an observable, determine based on its roles if it should be ignored in risk/observable
|
|
272
|
-
matching (e.g. Attacker role observables should not generate risk events)
|
|
273
|
-
:param observable: the Observable object we are checking the roles of
|
|
274
|
-
:returns: a bool indicating whether this observable should be ignored or not
|
|
275
|
-
"""
|
|
276
|
-
ignore = False
|
|
277
|
-
for role in observable.role:
|
|
278
|
-
if role in IGNORE_ROLES:
|
|
279
|
-
ignore = True
|
|
280
|
-
break
|
|
281
|
-
return ignore
|
|
282
|
-
|
|
283
|
-
def get_matched_observable(self, observables: list[Observable]) -> Observable:
|
|
228
|
+
def get_matched_risk_object(self, risk_objects: set[RiskObject]) -> RiskObject:
|
|
284
229
|
"""
|
|
285
|
-
Given a
|
|
286
|
-
:param
|
|
287
|
-
:returns: the matched
|
|
230
|
+
Given a set of risk objects, return the one this risk event matches
|
|
231
|
+
:param risk_objects: the list of risk objects we are checking against
|
|
232
|
+
:returns: the matched risk object
|
|
288
233
|
:raises ValidationFailed: if a match could not be made or if an expected field (based on
|
|
289
|
-
one of the
|
|
234
|
+
one of the risk objects) could not be found in the risk event
|
|
290
235
|
"""
|
|
291
236
|
# Return the cached match if already found
|
|
292
|
-
if self.
|
|
293
|
-
return self.
|
|
237
|
+
if self._matched_risk_object is not None:
|
|
238
|
+
return self._matched_risk_object
|
|
294
239
|
|
|
295
|
-
|
|
240
|
+
matched_risk_object: RiskObject | None = None
|
|
296
241
|
|
|
297
242
|
# Iterate over the obervables and check for a match
|
|
298
|
-
for
|
|
243
|
+
for risk_object in risk_objects:
|
|
299
244
|
# TODO (#252): Refactor and re-enable per-field validation of risk events
|
|
300
|
-
# Each the field name used in each
|
|
301
|
-
# if not hasattr(self,
|
|
245
|
+
# Each the field name used in each risk object shoud be present in the risk event
|
|
246
|
+
# if not hasattr(self, risk_object.field):
|
|
302
247
|
# raise ValidationFailed(
|
|
303
|
-
# f"
|
|
248
|
+
# f"Risk object field \"{risk_object.field}\" not found in risk event."
|
|
304
249
|
# )
|
|
305
250
|
|
|
306
|
-
# Try to match the risk_object against a specific
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
251
|
+
# Try to match the risk_object against a specific risk object
|
|
252
|
+
if self.source_field_name == risk_object.field:
|
|
253
|
+
# TODO (#347): enforce that field names are not repeated across risk objects as
|
|
254
|
+
# part of build/validate
|
|
255
|
+
if matched_risk_object is not None:
|
|
310
256
|
raise ValueError(
|
|
311
|
-
"Unexpected conditon: we don't expect
|
|
312
|
-
"
|
|
257
|
+
"Unexpected conditon: we don't expect multiple risk objects to use the "
|
|
258
|
+
"same field name, so we should not be able match the risk event to "
|
|
259
|
+
"multiple risk objects."
|
|
313
260
|
)
|
|
314
261
|
|
|
315
|
-
#
|
|
316
|
-
|
|
317
|
-
raise ValidationFailed(
|
|
318
|
-
"Risk event matched an observable with an invalid role: "
|
|
319
|
-
f"(name={observable.name}, type={observable.type}, role={observable.role})")
|
|
320
|
-
# NOTE: we explicitly do not break early as we want to check each observable
|
|
321
|
-
matched_observable = observable
|
|
262
|
+
# NOTE: we explicitly do not break early as we want to check each risk object
|
|
263
|
+
matched_risk_object = risk_object
|
|
322
264
|
|
|
323
|
-
# Ensure we were able to match the risk event to a specific
|
|
324
|
-
if
|
|
265
|
+
# Ensure we were able to match the risk event to a specific risk object
|
|
266
|
+
if matched_risk_object is None:
|
|
325
267
|
raise ValidationFailed(
|
|
326
|
-
f"Unable to match risk event (object={self.
|
|
327
|
-
f"{self.
|
|
328
|
-
"
|
|
329
|
-
"detection, as well as the risk event build process in contentctl."
|
|
268
|
+
f"Unable to match risk event (object={self.es_risk_object}, type="
|
|
269
|
+
f"{self.es_risk_object_type}, source_field_name={self.source_field_name}) to a "
|
|
270
|
+
"risk object in the detection; please check for errors in the risk object types for this "
|
|
271
|
+
"detection, as well as the risk event build process in contentctl (e.g. threat "
|
|
272
|
+
"objects aren't being converted to risk objects somehow)."
|
|
330
273
|
)
|
|
331
274
|
|
|
332
|
-
# Cache and return the matched
|
|
333
|
-
self.
|
|
334
|
-
return self.
|
|
275
|
+
# Cache and return the matched risk object
|
|
276
|
+
self._matched_risk_object = matched_risk_object
|
|
277
|
+
return self._matched_risk_object
|
contentctl/objects/story_tags.py
CHANGED
|
@@ -18,9 +18,8 @@ class StoryUseCase(str,Enum):
|
|
|
18
18
|
OTHER = "Other"
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
# TODO (#266): disable the use_enum_values configuration
|
|
22
21
|
class StoryTags(BaseModel):
|
|
23
|
-
model_config = ConfigDict(extra='forbid'
|
|
22
|
+
model_config = ConfigDict(extra='forbid')
|
|
24
23
|
category: List[StoryCategory] = Field(...,min_length=1)
|
|
25
24
|
product: List[SecurityContentProductName] = Field(...,min_length=1)
|
|
26
25
|
usecase: StoryUseCase = Field(...)
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from pydantic import BaseModel, HttpUrl, FilePath, Field
|
|
2
|
+
from pydantic import BaseModel, HttpUrl, FilePath, Field, ConfigDict
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
class TestAttackData(BaseModel):
|
|
6
|
+
model_config = ConfigDict(extra="forbid")
|
|
6
7
|
data: HttpUrl | FilePath = Field(...)
|
|
7
8
|
# TODO - should source and sourcetype should be mapped to a list
|
|
8
9
|
# of supported source and sourcetypes in a given environment?
|