contentctl 4.3.3__py3-none-any.whl → 4.3.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +0 -6
- contentctl/actions/initialize.py +28 -12
- contentctl/actions/inspect.py +189 -91
- contentctl/actions/validate.py +3 -7
- contentctl/api.py +1 -1
- contentctl/contentctl.py +3 -0
- contentctl/enrichments/attack_enrichment.py +51 -82
- contentctl/enrichments/cve_enrichment.py +2 -2
- contentctl/helper/splunk_app.py +141 -10
- contentctl/input/director.py +5 -12
- contentctl/objects/abstract_security_content_objects/detection_abstract.py +11 -8
- contentctl/objects/annotated_types.py +6 -0
- contentctl/objects/atomic.py +51 -77
- contentctl/objects/config.py +145 -22
- contentctl/objects/constants.py +4 -1
- contentctl/objects/correlation_search.py +35 -28
- contentctl/objects/detection_metadata.py +71 -0
- contentctl/objects/detection_stanza.py +79 -0
- contentctl/objects/detection_tags.py +11 -9
- contentctl/objects/enums.py +0 -2
- contentctl/objects/errors.py +187 -0
- contentctl/objects/mitre_attack_enrichment.py +2 -1
- contentctl/objects/risk_event.py +94 -76
- contentctl/objects/savedsearches_conf.py +196 -0
- contentctl/objects/story_tags.py +3 -3
- contentctl/output/conf_writer.py +4 -1
- contentctl/output/new_content_yml_output.py +4 -9
- {contentctl-4.3.3.dist-info → contentctl-4.3.5.dist-info}/METADATA +4 -4
- {contentctl-4.3.3.dist-info → contentctl-4.3.5.dist-info}/RECORD +32 -32
- contentctl/objects/ssa_detection.py +0 -157
- contentctl/objects/ssa_detection_tags.py +0 -138
- contentctl/objects/unit_test_old.py +0 -10
- contentctl/objects/unit_test_ssa.py +0 -31
- {contentctl-4.3.3.dist-info → contentctl-4.3.5.dist-info}/LICENSE.md +0 -0
- {contentctl-4.3.3.dist-info → contentctl-4.3.5.dist-info}/WHEEL +0 -0
- {contentctl-4.3.3.dist-info → contentctl-4.3.5.dist-info}/entry_points.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
import uuid
|
|
3
|
-
from typing import TYPE_CHECKING, List, Optional,
|
|
3
|
+
from typing import TYPE_CHECKING, List, Optional, Union
|
|
4
4
|
from pydantic import (
|
|
5
5
|
BaseModel,
|
|
6
6
|
Field,
|
|
@@ -32,8 +32,8 @@ from contentctl.objects.enums import (
|
|
|
32
32
|
RiskLevel,
|
|
33
33
|
SecurityContentProductName
|
|
34
34
|
)
|
|
35
|
-
from contentctl.objects.atomic import AtomicTest
|
|
36
|
-
|
|
35
|
+
from contentctl.objects.atomic import AtomicEnrichment, AtomicTest
|
|
36
|
+
from contentctl.objects.annotated_types import MITRE_ATTACK_ID_TYPE, CVE_TYPE
|
|
37
37
|
|
|
38
38
|
# TODO (#266): disable the use_enum_values configuration
|
|
39
39
|
class DetectionTags(BaseModel):
|
|
@@ -50,8 +50,10 @@ class DetectionTags(BaseModel):
|
|
|
50
50
|
def risk_score(self) -> int:
|
|
51
51
|
return round((self.confidence * self.impact)/100)
|
|
52
52
|
|
|
53
|
-
mitre_attack_id: List[
|
|
53
|
+
mitre_attack_id: List[MITRE_ATTACK_ID_TYPE] = []
|
|
54
54
|
nist: list[NistCategory] = []
|
|
55
|
+
|
|
56
|
+
# TODO (#249): Add pydantic validator to ensure observables are unique within a detection
|
|
55
57
|
observable: List[Observable] = []
|
|
56
58
|
message: str = Field(...)
|
|
57
59
|
product: list[SecurityContentProductName] = Field(..., min_length=1)
|
|
@@ -69,7 +71,7 @@ class DetectionTags(BaseModel):
|
|
|
69
71
|
else:
|
|
70
72
|
return RiskSeverity('low')
|
|
71
73
|
|
|
72
|
-
cve: List[
|
|
74
|
+
cve: List[CVE_TYPE] = []
|
|
73
75
|
atomic_guid: List[AtomicTest] = []
|
|
74
76
|
drilldown_search: Optional[str] = None
|
|
75
77
|
|
|
@@ -238,7 +240,7 @@ class DetectionTags(BaseModel):
|
|
|
238
240
|
if output_dto is None:
|
|
239
241
|
raise ValueError("Context not provided to detection.detection_tags.atomic_guid validator")
|
|
240
242
|
|
|
241
|
-
|
|
243
|
+
atomic_enrichment: AtomicEnrichment = output_dto.atomic_enrichment
|
|
242
244
|
|
|
243
245
|
matched_tests: List[AtomicTest] = []
|
|
244
246
|
missing_tests: List[UUID4] = []
|
|
@@ -252,7 +254,7 @@ class DetectionTags(BaseModel):
|
|
|
252
254
|
badly_formatted_guids.append(str(atomic_guid_str))
|
|
253
255
|
continue
|
|
254
256
|
try:
|
|
255
|
-
matched_tests.append(
|
|
257
|
+
matched_tests.append(atomic_enrichment.getAtomic(atomic_guid))
|
|
256
258
|
except Exception:
|
|
257
259
|
missing_tests.append(atomic_guid)
|
|
258
260
|
|
|
@@ -263,7 +265,7 @@ class DetectionTags(BaseModel):
|
|
|
263
265
|
f"\n\tPlease review the output above for potential exception(s) when parsing the "
|
|
264
266
|
"Atomic Red Team Repo."
|
|
265
267
|
"\n\tVerify that these auto_generated_guid exist and try updating/pulling the "
|
|
266
|
-
f"repo again
|
|
268
|
+
f"repo again: {[str(guid) for guid in missing_tests]}"
|
|
267
269
|
)
|
|
268
270
|
else:
|
|
269
271
|
missing_tests_string = ""
|
|
@@ -276,6 +278,6 @@ class DetectionTags(BaseModel):
|
|
|
276
278
|
raise ValueError(f"{bad_guids_string}{missing_tests_string}")
|
|
277
279
|
|
|
278
280
|
elif len(missing_tests) > 0:
|
|
279
|
-
|
|
281
|
+
raise ValueError(missing_tests_string)
|
|
280
282
|
|
|
281
283
|
return matched_tests + [AtomicTest.AtomicTestWhenTestIsMissing(test) for test in missing_tests]
|
contentctl/objects/enums.py
CHANGED
|
@@ -54,7 +54,6 @@ class SecurityContentType(enum.Enum):
|
|
|
54
54
|
deployments = 7
|
|
55
55
|
investigations = 8
|
|
56
56
|
unit_tests = 9
|
|
57
|
-
ssa_detections = 10
|
|
58
57
|
data_sources = 11
|
|
59
58
|
|
|
60
59
|
# Bringing these changes back in line will take some time after
|
|
@@ -69,7 +68,6 @@ class SecurityContentType(enum.Enum):
|
|
|
69
68
|
|
|
70
69
|
class SecurityContentProduct(enum.Enum):
|
|
71
70
|
SPLUNK_APP = 1
|
|
72
|
-
SSA = 2
|
|
73
71
|
API = 3
|
|
74
72
|
CUSTOM = 4
|
|
75
73
|
|
contentctl/objects/errors.py
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
|
|
1
5
|
class ValidationFailed(Exception):
|
|
2
6
|
"""Indicates not an error in execution, but a validation failure"""
|
|
3
7
|
pass
|
|
@@ -16,3 +20,186 @@ class ServerError(IntegrationTestingError):
|
|
|
16
20
|
class ClientError(IntegrationTestingError):
|
|
17
21
|
"""An error encounterd during integration testing, on the client's side (locally)"""
|
|
18
22
|
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MetadataValidationError(Exception, ABC):
|
|
26
|
+
"""
|
|
27
|
+
Base class for any errors arising from savedsearches.conf detection metadata validation
|
|
28
|
+
"""
|
|
29
|
+
# The name of the rule the error relates to
|
|
30
|
+
rule_name: str
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
@abstractmethod
|
|
34
|
+
def long_message(self) -> str:
|
|
35
|
+
"""
|
|
36
|
+
A long-form error message
|
|
37
|
+
:returns: a str, the message
|
|
38
|
+
"""
|
|
39
|
+
raise NotImplementedError()
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def short_message(self) -> str:
|
|
44
|
+
"""
|
|
45
|
+
A short-form error message
|
|
46
|
+
:returns: a str, the message
|
|
47
|
+
"""
|
|
48
|
+
raise NotImplementedError()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DetectionMissingError(MetadataValidationError):
|
|
52
|
+
"""
|
|
53
|
+
An error indicating a detection in the prior build could not be found in the current build
|
|
54
|
+
"""
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
rule_name: str,
|
|
58
|
+
*args: object
|
|
59
|
+
) -> None:
|
|
60
|
+
self.rule_name = rule_name
|
|
61
|
+
super().__init__(self.long_message, *args)
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def long_message(self) -> str:
|
|
65
|
+
"""
|
|
66
|
+
A long-form error message
|
|
67
|
+
:returns: a str, the message
|
|
68
|
+
"""
|
|
69
|
+
return (
|
|
70
|
+
f"Rule '{self.rule_name}' in previous build not found in current build; "
|
|
71
|
+
"detection may have been removed or renamed."
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def short_message(self) -> str:
|
|
76
|
+
"""
|
|
77
|
+
A short-form error message
|
|
78
|
+
:returns: a str, the message
|
|
79
|
+
"""
|
|
80
|
+
return (
|
|
81
|
+
"Detection from previous build not found in current build."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class DetectionIDError(MetadataValidationError):
|
|
86
|
+
"""
|
|
87
|
+
An error indicating the detection ID may have changed between builds
|
|
88
|
+
"""
|
|
89
|
+
# The ID from the current build
|
|
90
|
+
current_id: UUID
|
|
91
|
+
|
|
92
|
+
# The ID from the previous build
|
|
93
|
+
previous_id: UUID
|
|
94
|
+
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
rule_name: str,
|
|
98
|
+
current_id: UUID,
|
|
99
|
+
previous_id: UUID,
|
|
100
|
+
*args: object
|
|
101
|
+
) -> None:
|
|
102
|
+
self.rule_name = rule_name
|
|
103
|
+
self.current_id = current_id
|
|
104
|
+
self.previous_id = previous_id
|
|
105
|
+
super().__init__(self.long_message, *args)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def long_message(self) -> str:
|
|
109
|
+
"""
|
|
110
|
+
A long-form error message
|
|
111
|
+
:returns: a str, the message
|
|
112
|
+
"""
|
|
113
|
+
return (
|
|
114
|
+
f"Rule '{self.rule_name}' has ID {self.current_id} in current build "
|
|
115
|
+
f"and {self.previous_id} in previous build; detection IDs and "
|
|
116
|
+
"names should not change for the same detection between releases."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def short_message(self) -> str:
|
|
121
|
+
"""
|
|
122
|
+
A short-form error message
|
|
123
|
+
:returns: a str, the message
|
|
124
|
+
"""
|
|
125
|
+
return (
|
|
126
|
+
f"Detection ID {self.current_id} in current build does not match ID {self.previous_id} in previous build."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class VersioningError(MetadataValidationError, ABC):
|
|
131
|
+
"""
|
|
132
|
+
A base class for any metadata validation errors relating to detection versioning
|
|
133
|
+
"""
|
|
134
|
+
# The version in the current build
|
|
135
|
+
current_version: int
|
|
136
|
+
|
|
137
|
+
# The version in the previous build
|
|
138
|
+
previous_version: int
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
rule_name: str,
|
|
143
|
+
current_version: int,
|
|
144
|
+
previous_version: int,
|
|
145
|
+
*args: object
|
|
146
|
+
) -> None:
|
|
147
|
+
self.rule_name = rule_name
|
|
148
|
+
self.current_version = current_version
|
|
149
|
+
self.previous_version = previous_version
|
|
150
|
+
super().__init__(self.long_message, *args)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class VersionDecrementedError(VersioningError):
|
|
154
|
+
"""
|
|
155
|
+
An error indicating the version number went down between builds
|
|
156
|
+
"""
|
|
157
|
+
@property
|
|
158
|
+
def long_message(self) -> str:
|
|
159
|
+
"""
|
|
160
|
+
A long-form error message
|
|
161
|
+
:returns: a str, the message
|
|
162
|
+
"""
|
|
163
|
+
return (
|
|
164
|
+
f"Rule '{self.rule_name}' has version {self.current_version} in "
|
|
165
|
+
f"current build and {self.previous_version} in previous build; "
|
|
166
|
+
"detection versions cannot decrease in successive builds."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def short_message(self) -> str:
|
|
171
|
+
"""
|
|
172
|
+
A short-form error message
|
|
173
|
+
:returns: a str, the message
|
|
174
|
+
"""
|
|
175
|
+
return (
|
|
176
|
+
f"Detection version ({self.current_version}) in current build is less than version "
|
|
177
|
+
f"({self.previous_version}) in previous build."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class VersionBumpingError(VersioningError):
|
|
182
|
+
"""
|
|
183
|
+
An error indicating the detection changed but its version wasn't bumped appropriately
|
|
184
|
+
"""
|
|
185
|
+
@property
|
|
186
|
+
def long_message(self) -> str:
|
|
187
|
+
"""
|
|
188
|
+
A long-form error message
|
|
189
|
+
:returns: a str, the message
|
|
190
|
+
"""
|
|
191
|
+
return (
|
|
192
|
+
f"Rule '{self.rule_name}' has changed in current build compared to previous "
|
|
193
|
+
"build (stanza hashes differ); the detection version should be bumped "
|
|
194
|
+
f"to at least {self.previous_version + 1}."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def short_message(self) -> str:
|
|
199
|
+
"""
|
|
200
|
+
A short-form error message
|
|
201
|
+
:returns: a str, the message
|
|
202
|
+
"""
|
|
203
|
+
return (
|
|
204
|
+
f"Detection version in current build should be bumped to at least {self.previous_version + 1}."
|
|
205
|
+
)
|
|
@@ -3,6 +3,7 @@ from pydantic import BaseModel, Field, ConfigDict, HttpUrl, field_validator
|
|
|
3
3
|
from typing import List, Annotated
|
|
4
4
|
from enum import StrEnum
|
|
5
5
|
import datetime
|
|
6
|
+
from contentctl.objects.annotated_types import MITRE_ATTACK_ID_TYPE
|
|
6
7
|
|
|
7
8
|
class MitreTactics(StrEnum):
|
|
8
9
|
RECONNAISSANCE = "Reconnaissance"
|
|
@@ -85,7 +86,7 @@ class MitreAttackGroup(BaseModel):
|
|
|
85
86
|
# TODO (#266): disable the use_enum_values configuration
|
|
86
87
|
class MitreAttackEnrichment(BaseModel):
|
|
87
88
|
ConfigDict(use_enum_values=True)
|
|
88
|
-
mitre_attack_id:
|
|
89
|
+
mitre_attack_id: MITRE_ATTACK_ID_TYPE = Field(...)
|
|
89
90
|
mitre_attack_technique: str = Field(...)
|
|
90
91
|
mitre_attack_tactics: List[MitreTactics] = Field(...)
|
|
91
92
|
mitre_attack_groups: List[str] = Field(...)
|
contentctl/objects/risk_event.py
CHANGED
|
@@ -1,32 +1,51 @@
|
|
|
1
1
|
import re
|
|
2
|
-
from typing import Union, Optional
|
|
3
2
|
|
|
4
|
-
from pydantic import BaseModel, Field, PrivateAttr, field_validator
|
|
3
|
+
from pydantic import BaseModel, Field, PrivateAttr, field_validator, computed_field
|
|
5
4
|
|
|
6
5
|
from contentctl.objects.errors import ValidationFailed
|
|
7
6
|
from contentctl.objects.detection import Detection
|
|
8
7
|
from contentctl.objects.observable import Observable
|
|
9
8
|
|
|
10
|
-
# TODO (
|
|
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)
|
|
11
12
|
TYPE_MAP: dict[str, list[str]] = {
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
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
|
+
]
|
|
15
45
|
}
|
|
16
|
-
# TODO (PEX-433): 'Email Address', 'File Name', 'File Hash', 'Other', 'User Name', 'File',
|
|
17
|
-
# 'Process Name'
|
|
18
46
|
|
|
19
|
-
#
|
|
47
|
+
# Roles that should not generate risks
|
|
20
48
|
IGNORE_ROLES: list[str] = ["Attacker"]
|
|
21
|
-
# Known valid roles: Victim, Parent Process, Child Process
|
|
22
|
-
# TODO (PEX-433): 'Other', 'Target', 'Unknown'
|
|
23
|
-
# TODO (PEX-433): is Other a valid role
|
|
24
|
-
|
|
25
|
-
# TODO (PEX-433): do we need User Name in conjunction w/ User? User Name doesn't get mapped to
|
|
26
|
-
# "user" in risk events
|
|
27
|
-
# TODO (PEX-433): similarly, do we need Process and Process Name?
|
|
28
|
-
|
|
29
|
-
RESERVED_FIELDS = ["host"]
|
|
30
49
|
|
|
31
50
|
|
|
32
51
|
class RiskEvent(BaseModel):
|
|
@@ -36,7 +55,7 @@ class RiskEvent(BaseModel):
|
|
|
36
55
|
search_name: str
|
|
37
56
|
|
|
38
57
|
# The subject of the risk event (e.g. a username, process name, system name, account ID, etc.)
|
|
39
|
-
risk_object:
|
|
58
|
+
risk_object: int | str
|
|
40
59
|
|
|
41
60
|
# The type of the risk object (e.g. user, system, or other)
|
|
42
61
|
risk_object_type: str
|
|
@@ -59,8 +78,12 @@ class RiskEvent(BaseModel):
|
|
|
59
78
|
default=[]
|
|
60
79
|
)
|
|
61
80
|
|
|
81
|
+
# Contributing events search query (we use this to derive the corresponding field from the
|
|
82
|
+
# observables)
|
|
83
|
+
contributing_events_search: str
|
|
84
|
+
|
|
62
85
|
# Private attribute caching the observable this RiskEvent is mapped to
|
|
63
|
-
_matched_observable:
|
|
86
|
+
_matched_observable: Observable | None = PrivateAttr(default=None)
|
|
64
87
|
|
|
65
88
|
class Config:
|
|
66
89
|
# Allowing fields that aren't explicitly defined to be passed since some of the risk event's
|
|
@@ -69,7 +92,7 @@ class RiskEvent(BaseModel):
|
|
|
69
92
|
|
|
70
93
|
@field_validator("annotations_mitre_attack", "analyticstories", mode="before")
|
|
71
94
|
@classmethod
|
|
72
|
-
def _convert_str_value_to_singleton(cls, v:
|
|
95
|
+
def _convert_str_value_to_singleton(cls, v: str | list[str]) -> list[str]:
|
|
73
96
|
"""
|
|
74
97
|
Given a value, determine if its a list or a single str value; if a single value, return as a
|
|
75
98
|
singleton. Do nothing if anything else.
|
|
@@ -79,6 +102,25 @@ class RiskEvent(BaseModel):
|
|
|
79
102
|
else:
|
|
80
103
|
return [v]
|
|
81
104
|
|
|
105
|
+
@computed_field
|
|
106
|
+
@property
|
|
107
|
+
def source_field_name(self) -> str:
|
|
108
|
+
"""
|
|
109
|
+
A cached derivation of the source field name the risk event corresponds to in the relevant
|
|
110
|
+
event(s). Useful for mapping back to an observable in the detection.
|
|
111
|
+
"""
|
|
112
|
+
pattern = re.compile(
|
|
113
|
+
r"\| savedsearch \"" + self.search_name + r"\" \| search (?P<field>[^=]+)=.+"
|
|
114
|
+
)
|
|
115
|
+
match = pattern.search(self.contributing_events_search)
|
|
116
|
+
if match is None:
|
|
117
|
+
raise ValueError(
|
|
118
|
+
"Unable to parse source field name from risk event using "
|
|
119
|
+
f"'contributing_events_search' ('{self.contributing_events_search}') using "
|
|
120
|
+
f"pattern: {pattern}"
|
|
121
|
+
)
|
|
122
|
+
return match.group("field")
|
|
123
|
+
|
|
82
124
|
def validate_against_detection(self, detection: Detection) -> None:
|
|
83
125
|
"""
|
|
84
126
|
Given the associated detection, validate the risk event against its fields
|
|
@@ -108,10 +150,8 @@ class RiskEvent(BaseModel):
|
|
|
108
150
|
# Check risk_message
|
|
109
151
|
self.validate_risk_message(detection)
|
|
110
152
|
|
|
111
|
-
# TODO (PEX-433): Re-enable this check once we have refined the logic and reduced the false
|
|
112
|
-
# positive rate in risk/obseravble matching
|
|
113
153
|
# Check several conditions against the observables
|
|
114
|
-
|
|
154
|
+
self.validate_risk_against_observables(detection.tags.observable)
|
|
115
155
|
|
|
116
156
|
def validate_mitre_ids(self, detection: Detection) -> None:
|
|
117
157
|
"""
|
|
@@ -199,7 +239,11 @@ class RiskEvent(BaseModel):
|
|
|
199
239
|
if self.risk_object_type != expected_type:
|
|
200
240
|
raise ValidationFailed(
|
|
201
241
|
f"The risk object type ({self.risk_object_type}) does not match the expected type "
|
|
202
|
-
f"based on the matched observable ({matched_observable.type}
|
|
242
|
+
f"based on the matched observable ({matched_observable.type}->{expected_type}): "
|
|
243
|
+
f"risk=(object={self.risk_object}, type={self.risk_object_type}, "
|
|
244
|
+
f"source_field_name={self.source_field_name}), "
|
|
245
|
+
f"observable=(name={matched_observable.name}, type={matched_observable.type}, "
|
|
246
|
+
f"role={matched_observable.role})"
|
|
203
247
|
)
|
|
204
248
|
|
|
205
249
|
@staticmethod
|
|
@@ -220,8 +264,6 @@ class RiskEvent(BaseModel):
|
|
|
220
264
|
f"Observable type {observable_type} does not have a mapping to a risk type in TYPE_MAP"
|
|
221
265
|
)
|
|
222
266
|
|
|
223
|
-
# TODO (PEX-433): should this be an observable instance method? It feels less relevant to
|
|
224
|
-
# observables themselves, as it's really only relevant to the handling of risk events
|
|
225
267
|
@staticmethod
|
|
226
268
|
def ignore_observable(observable: Observable) -> bool:
|
|
227
269
|
"""
|
|
@@ -230,8 +272,6 @@ class RiskEvent(BaseModel):
|
|
|
230
272
|
:param observable: the Observable object we are checking the roles of
|
|
231
273
|
:returns: a bool indicating whether this observable should be ignored or not
|
|
232
274
|
"""
|
|
233
|
-
# TODO (PEX-433): could there be a case where an observable has both an Attacker and Victim
|
|
234
|
-
# (or equivalent) role? If so, how should we handle ignoring it?
|
|
235
275
|
ignore = False
|
|
236
276
|
for role in observable.role:
|
|
237
277
|
if role in IGNORE_ROLES:
|
|
@@ -239,29 +279,6 @@ class RiskEvent(BaseModel):
|
|
|
239
279
|
break
|
|
240
280
|
return ignore
|
|
241
281
|
|
|
242
|
-
# TODO (PEX-433): two possibilities: alway check for the field itself and the field prefixed
|
|
243
|
-
# w/ "orig_" OR more explicitly maintain a list of known "reserved fields", like "host". I
|
|
244
|
-
# think I like option 2 better as it can have fewer unknown side effects
|
|
245
|
-
def matches_observable(self, observable: Observable) -> bool:
|
|
246
|
-
"""
|
|
247
|
-
Given an observable, check if the risk event matches is
|
|
248
|
-
:param observable: the Observable object we are comparing the risk event against
|
|
249
|
-
:returns: bool indicating a match or not
|
|
250
|
-
"""
|
|
251
|
-
# When field names collide w/ reserved fields in Splunk events (e.g. sourcetype or host)
|
|
252
|
-
# they get prefixed w/ "orig_"
|
|
253
|
-
attribute_name = observable.name
|
|
254
|
-
if attribute_name in RESERVED_FIELDS:
|
|
255
|
-
attribute_name = f"orig_{attribute_name}"
|
|
256
|
-
|
|
257
|
-
# Retrieve the value of this attribute and see if it matches the risk_object
|
|
258
|
-
value: Union[str, list[str]] = getattr(self, attribute_name)
|
|
259
|
-
if isinstance(value, str):
|
|
260
|
-
value = [value]
|
|
261
|
-
|
|
262
|
-
# The value of the attribute may be a list of values, so check for any matches
|
|
263
|
-
return self.risk_object in value
|
|
264
|
-
|
|
265
282
|
def get_matched_observable(self, observables: list[Observable]) -> Observable:
|
|
266
283
|
"""
|
|
267
284
|
Given a list of observables, return the one this risk event matches
|
|
@@ -274,40 +291,41 @@ class RiskEvent(BaseModel):
|
|
|
274
291
|
if self._matched_observable is not None:
|
|
275
292
|
return self._matched_observable
|
|
276
293
|
|
|
277
|
-
matched_observable:
|
|
294
|
+
matched_observable: Observable | None = None
|
|
278
295
|
|
|
279
296
|
# Iterate over the obervables and check for a match
|
|
280
297
|
for observable in observables:
|
|
298
|
+
# TODO (#252): Refactor and re-enable per-field validation of risk events
|
|
281
299
|
# Each the field name used in each observable shoud be present in the risk event
|
|
282
|
-
#
|
|
283
|
-
#
|
|
284
|
-
#
|
|
285
|
-
#
|
|
286
|
-
if not hasattr(self, observable.name):
|
|
287
|
-
raise ValidationFailed(
|
|
288
|
-
f"Observable field \"{observable.name}\" not found in risk event."
|
|
289
|
-
)
|
|
300
|
+
# if not hasattr(self, observable.name):
|
|
301
|
+
# raise ValidationFailed(
|
|
302
|
+
# f"Observable field \"{observable.name}\" not found in risk event."
|
|
303
|
+
# )
|
|
290
304
|
|
|
291
305
|
# Try to match the risk_object against a specific observable for the obervables with
|
|
292
|
-
# a valid role (some, like Attacker,
|
|
293
|
-
if
|
|
294
|
-
if
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
306
|
+
# a valid role (some, like Attacker, shouldn't get converted to risk events)
|
|
307
|
+
if self.source_field_name == observable.name:
|
|
308
|
+
if matched_observable is not None:
|
|
309
|
+
raise ValueError(
|
|
310
|
+
"Unexpected conditon: we don't expect the source event field "
|
|
311
|
+
"corresponding to an observables field name to be repeated."
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Report any risk events we find that shouldn't be there
|
|
315
|
+
if RiskEvent.ignore_observable(observable):
|
|
316
|
+
raise ValidationFailed(
|
|
317
|
+
"Risk event matched an observable with an invalid role: "
|
|
318
|
+
f"(name={observable.name}, type={observable.type}, role={observable.role})")
|
|
319
|
+
# NOTE: we explicitly do not break early as we want to check each observable
|
|
320
|
+
matched_observable = observable
|
|
305
321
|
|
|
306
322
|
# Ensure we were able to match the risk event to a specific observable
|
|
307
323
|
if matched_observable is None:
|
|
308
324
|
raise ValidationFailed(
|
|
309
|
-
f"Unable to match risk event ({self.risk_object},
|
|
310
|
-
"
|
|
325
|
+
f"Unable to match risk event (object={self.risk_object}, type="
|
|
326
|
+
f"{self.risk_object_type}, source_field_name={self.source_field_name}) to an "
|
|
327
|
+
"observable; please check for errors in the observable roles/types for this "
|
|
328
|
+
"detection, as well as the risk event build process in contentctl."
|
|
311
329
|
)
|
|
312
330
|
|
|
313
331
|
# Cache and return the matched observable
|