contentctl 4.3.2__py3-none-any.whl → 4.3.4__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 +35 -27
- contentctl/actions/detection_testing/views/DetectionTestingView.py +64 -38
- contentctl/actions/detection_testing/views/DetectionTestingViewCLI.py +1 -0
- contentctl/actions/detection_testing/views/DetectionTestingViewFile.py +3 -5
- contentctl/actions/test.py +55 -32
- contentctl/contentctl.py +3 -6
- contentctl/enrichments/attack_enrichment.py +2 -1
- contentctl/enrichments/cve_enrichment.py +2 -2
- contentctl/objects/abstract_security_content_objects/detection_abstract.py +183 -90
- contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +1 -0
- contentctl/objects/annotated_types.py +6 -0
- contentctl/objects/base_test.py +1 -0
- contentctl/objects/base_test_result.py +1 -0
- contentctl/objects/config.py +27 -12
- contentctl/objects/correlation_search.py +35 -28
- contentctl/objects/detection_tags.py +8 -3
- contentctl/objects/integration_test.py +3 -5
- contentctl/objects/integration_test_result.py +1 -5
- contentctl/objects/investigation.py +1 -0
- contentctl/objects/manual_test.py +32 -0
- contentctl/objects/manual_test_result.py +8 -0
- contentctl/objects/mitre_attack_enrichment.py +3 -1
- contentctl/objects/risk_event.py +94 -76
- contentctl/objects/ssa_detection.py +1 -0
- contentctl/objects/story_tags.py +5 -3
- contentctl/objects/{unit_test_attack_data.py → test_attack_data.py} +4 -5
- contentctl/objects/test_group.py +3 -3
- contentctl/objects/unit_test.py +4 -11
- contentctl/output/templates/savedsearches_detections.j2 +1 -1
- {contentctl-4.3.2.dist-info → contentctl-4.3.4.dist-info}/METADATA +8 -8
- {contentctl-4.3.2.dist-info → contentctl-4.3.4.dist-info}/RECORD +34 -31
- {contentctl-4.3.2.dist-info → contentctl-4.3.4.dist-info}/LICENSE.md +0 -0
- {contentctl-4.3.2.dist-info → contentctl-4.3.4.dist-info}/WHEEL +0 -0
- {contentctl-4.3.2.dist-info → contentctl-4.3.4.dist-info}/entry_points.txt +0 -0
|
@@ -575,10 +575,11 @@ class CorrelationSearch(BaseModel):
|
|
|
575
575
|
self.logger.debug(f"Using cached risk events ({len(self._risk_events)} total).")
|
|
576
576
|
return self._risk_events
|
|
577
577
|
|
|
578
|
+
# TODO (#248): Refactor risk/notable querying to pin to a single savedsearch ID
|
|
578
579
|
# Search for all risk events from a single scheduled search (indicated by orig_sid)
|
|
579
580
|
query = (
|
|
580
581
|
f'search index=risk search_name="{self.name}" [search index=risk search '
|
|
581
|
-
f'search_name="{self.name}" |
|
|
582
|
+
f'search_name="{self.name}" | tail 1 | fields orig_sid] | tojson'
|
|
582
583
|
)
|
|
583
584
|
result_iterator = self._search(query)
|
|
584
585
|
|
|
@@ -643,7 +644,7 @@ class CorrelationSearch(BaseModel):
|
|
|
643
644
|
# Search for all notable events from a single scheduled search (indicated by orig_sid)
|
|
644
645
|
query = (
|
|
645
646
|
f'search index=notable search_name="{self.name}" [search index=notable search '
|
|
646
|
-
f'search_name="{self.name}" |
|
|
647
|
+
f'search_name="{self.name}" | tail 1 | fields orig_sid] | tojson'
|
|
647
648
|
)
|
|
648
649
|
result_iterator = self._search(query)
|
|
649
650
|
|
|
@@ -686,15 +687,17 @@ class CorrelationSearch(BaseModel):
|
|
|
686
687
|
check the risks/notables
|
|
687
688
|
:returns: an IntegrationTestResult on failure; None on success
|
|
688
689
|
"""
|
|
689
|
-
# TODO (PEX-433): Re-enable this check once we have refined the logic and reduced the false
|
|
690
|
-
# positive rate in risk/obseravble matching
|
|
691
690
|
# Create a mapping of the relevant observables to counters
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
#
|
|
696
|
-
#
|
|
697
|
-
|
|
691
|
+
observables = CorrelationSearch._get_relevant_observables(self.detection.tags.observable)
|
|
692
|
+
observable_counts: dict[str, int] = {str(x): 0 for x in observables}
|
|
693
|
+
|
|
694
|
+
# NOTE: we intentionally want this to be an error state and not a failure state, as
|
|
695
|
+
# ultimately this validation should be handled during the build process
|
|
696
|
+
if len(observables) != len(observable_counts):
|
|
697
|
+
raise ClientError(
|
|
698
|
+
f"At least two observables in '{self.detection.name}' have the same name; "
|
|
699
|
+
"each observable for a detection should be unique."
|
|
700
|
+
)
|
|
698
701
|
|
|
699
702
|
# Get the risk events; note that we use the cached risk events, expecting they were
|
|
700
703
|
# saved by a prior call to risk_event_exists
|
|
@@ -710,25 +713,29 @@ class CorrelationSearch(BaseModel):
|
|
|
710
713
|
)
|
|
711
714
|
event.validate_against_detection(self.detection)
|
|
712
715
|
|
|
713
|
-
# TODO (PEX-433): Re-enable this check once we have refined the logic and reduced the
|
|
714
|
-
# false positive rate in risk/obseravble matching
|
|
715
716
|
# Update observable count based on match
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
#
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
717
|
+
matched_observable = event.get_matched_observable(self.detection.tags.observable)
|
|
718
|
+
self.logger.debug(
|
|
719
|
+
f"Matched risk event (object={event.risk_object}, type={event.risk_object_type}) "
|
|
720
|
+
f"to observable (name={matched_observable.name}, type={matched_observable.type}, "
|
|
721
|
+
f"role={matched_observable.role}) using the source field "
|
|
722
|
+
f"'{event.source_field_name}'"
|
|
723
|
+
)
|
|
724
|
+
observable_counts[str(matched_observable)] += 1
|
|
725
|
+
|
|
726
|
+
# Report any observables which did not have at least one match to a risk event
|
|
727
|
+
for observable in observables:
|
|
728
|
+
self.logger.debug(
|
|
729
|
+
f"Matched observable (name={observable.name}, type={observable.type}, "
|
|
730
|
+
f"role={observable.role}) to {observable_counts[str(observable)]} risk events."
|
|
731
|
+
)
|
|
732
|
+
if observable_counts[str(observable)] == 0:
|
|
733
|
+
raise ValidationFailed(
|
|
734
|
+
f"Observable (name={observable.name}, type={observable.type}, "
|
|
735
|
+
f"role={observable.role}) was not matched to any risk events."
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
# TODO (#250): Re-enable and refactor code that validates the specific risk counts
|
|
732
739
|
# Validate risk events in aggregate; we should have an equal amount of risk events for each
|
|
733
740
|
# relevant observable, and the total count should match the total number of events
|
|
734
741
|
# individual_count: Optional[int] = None
|
|
@@ -33,8 +33,9 @@ from contentctl.objects.enums import (
|
|
|
33
33
|
SecurityContentProductName
|
|
34
34
|
)
|
|
35
35
|
from contentctl.objects.atomic import AtomicTest
|
|
36
|
+
from contentctl.objects.annotated_types import MITRE_ATTACK_ID_TYPE, CVE_TYPE
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
38
39
|
class DetectionTags(BaseModel):
|
|
39
40
|
# detection spec
|
|
40
41
|
model_config = ConfigDict(use_enum_values=True, validate_default=False)
|
|
@@ -49,8 +50,10 @@ class DetectionTags(BaseModel):
|
|
|
49
50
|
def risk_score(self) -> int:
|
|
50
51
|
return round((self.confidence * self.impact)/100)
|
|
51
52
|
|
|
52
|
-
mitre_attack_id: List[
|
|
53
|
+
mitre_attack_id: List[MITRE_ATTACK_ID_TYPE] = []
|
|
53
54
|
nist: list[NistCategory] = []
|
|
55
|
+
|
|
56
|
+
# TODO (#249): Add pydantic validator to ensure observables are unique within a detection
|
|
54
57
|
observable: List[Observable] = []
|
|
55
58
|
message: str = Field(...)
|
|
56
59
|
product: list[SecurityContentProductName] = Field(..., min_length=1)
|
|
@@ -68,7 +71,7 @@ class DetectionTags(BaseModel):
|
|
|
68
71
|
else:
|
|
69
72
|
return RiskSeverity('low')
|
|
70
73
|
|
|
71
|
-
cve: List[
|
|
74
|
+
cve: List[CVE_TYPE] = []
|
|
72
75
|
atomic_guid: List[AtomicTest] = []
|
|
73
76
|
drilldown_search: Optional[str] = None
|
|
74
77
|
|
|
@@ -106,6 +109,8 @@ class DetectionTags(BaseModel):
|
|
|
106
109
|
# TODO (#221): mappings should be fleshed out into a proper class
|
|
107
110
|
mappings: Optional[List] = None
|
|
108
111
|
# annotations: Optional[dict] = None
|
|
112
|
+
|
|
113
|
+
# TODO (#268): Validate manual_test has length > 0 if not None
|
|
109
114
|
manual_test: Optional[str] = None
|
|
110
115
|
|
|
111
116
|
# The following validator is temporarily disabled pending further discussions
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
from typing import Union
|
|
2
|
-
|
|
3
1
|
from pydantic import Field
|
|
4
2
|
|
|
5
3
|
from contentctl.objects.base_test import BaseTest, TestType
|
|
@@ -13,10 +11,10 @@ class IntegrationTest(BaseTest):
|
|
|
13
11
|
An integration test for a detection against ES
|
|
14
12
|
"""
|
|
15
13
|
# The test type (integration)
|
|
16
|
-
test_type: TestType = Field(TestType.INTEGRATION)
|
|
14
|
+
test_type: TestType = Field(default=TestType.INTEGRATION)
|
|
17
15
|
|
|
18
16
|
# The test result
|
|
19
|
-
result:
|
|
17
|
+
result: IntegrationTestResult | None = None
|
|
20
18
|
|
|
21
19
|
@classmethod
|
|
22
20
|
def derive_from_unit_test(cls, unit_test: UnitTest) -> "IntegrationTest":
|
|
@@ -36,7 +34,7 @@ class IntegrationTest(BaseTest):
|
|
|
36
34
|
Skip the test by setting its result status
|
|
37
35
|
:param message: the reason for skipping
|
|
38
36
|
"""
|
|
39
|
-
self.result = IntegrationTestResult(
|
|
37
|
+
self.result = IntegrationTestResult( # type: ignore
|
|
40
38
|
message=message,
|
|
41
39
|
status=TestResultStatus.SKIP
|
|
42
40
|
)
|
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
from typing import Optional
|
|
2
1
|
from contentctl.objects.base_test_result import BaseTestResult
|
|
3
2
|
|
|
4
3
|
|
|
5
|
-
SAVED_SEARCH_TEMPLATE = "{server}:{web_port}/en-US/{path}"
|
|
6
|
-
|
|
7
|
-
|
|
8
4
|
class IntegrationTestResult(BaseTestResult):
|
|
9
5
|
"""
|
|
10
6
|
An integration test result
|
|
11
7
|
"""
|
|
12
8
|
# the total time we slept waiting for the detection to fire after activating it
|
|
13
|
-
wait_duration:
|
|
9
|
+
wait_duration: int | None = None
|
|
@@ -9,6 +9,7 @@ from contentctl.objects.enums import DataModel
|
|
|
9
9
|
from contentctl.objects.investigation_tags import InvestigationTags
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
12
13
|
class Investigation(SecurityContentObject):
|
|
13
14
|
model_config = ConfigDict(use_enum_values=True,validate_default=False)
|
|
14
15
|
type: str = Field(...,pattern="^Investigation$")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from contentctl.objects.test_attack_data import TestAttackData
|
|
6
|
+
from contentctl.objects.manual_test_result import ManualTestResult
|
|
7
|
+
from contentctl.objects.base_test import BaseTest, TestType
|
|
8
|
+
from contentctl.objects.base_test_result import TestResultStatus
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ManualTest(BaseTest):
|
|
12
|
+
"""
|
|
13
|
+
A manual test for a detection
|
|
14
|
+
"""
|
|
15
|
+
# The test type (manual)
|
|
16
|
+
test_type: TestType = Field(default=TestType.MANUAL)
|
|
17
|
+
|
|
18
|
+
# The attack data to be ingested for the manual test
|
|
19
|
+
attack_data: list[TestAttackData]
|
|
20
|
+
|
|
21
|
+
# The result of the manual test
|
|
22
|
+
result: ManualTestResult | None = None
|
|
23
|
+
|
|
24
|
+
def skip(self, message: str) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Skip the test by setting its result status
|
|
27
|
+
:param message: the reason for skipping
|
|
28
|
+
"""
|
|
29
|
+
self.result = ManualTestResult( # type: ignore
|
|
30
|
+
message=message,
|
|
31
|
+
status=TestResultStatus.SKIP
|
|
32
|
+
)
|
|
@@ -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"
|
|
@@ -82,9 +83,10 @@ class MitreAttackGroup(BaseModel):
|
|
|
82
83
|
return []
|
|
83
84
|
return contributors
|
|
84
85
|
|
|
86
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
85
87
|
class MitreAttackEnrichment(BaseModel):
|
|
86
88
|
ConfigDict(use_enum_values=True)
|
|
87
|
-
mitre_attack_id:
|
|
89
|
+
mitre_attack_id: MITRE_ATTACK_ID_TYPE = Field(...)
|
|
88
90
|
mitre_attack_technique: str = Field(...)
|
|
89
91
|
mitre_attack_tactics: List[MitreTactics] = Field(...)
|
|
90
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
|
contentctl/objects/story_tags.py
CHANGED
|
@@ -6,7 +6,7 @@ from enum import Enum
|
|
|
6
6
|
|
|
7
7
|
from contentctl.objects.mitre_attack_enrichment import MitreAttackEnrichment
|
|
8
8
|
from contentctl.objects.enums import StoryCategory, DataModel, KillChainPhase, SecurityContentProductName
|
|
9
|
-
|
|
9
|
+
from contentctl.objects.annotated_types import CVE_TYPE,MITRE_ATTACK_ID_TYPE
|
|
10
10
|
|
|
11
11
|
class StoryUseCase(str,Enum):
|
|
12
12
|
FRAUD_DETECTION = "Fraud Detection"
|
|
@@ -17,6 +17,8 @@ class StoryUseCase(str,Enum):
|
|
|
17
17
|
INSIDER_THREAT = "Insider Threat"
|
|
18
18
|
OTHER = "Other"
|
|
19
19
|
|
|
20
|
+
|
|
21
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
20
22
|
class StoryTags(BaseModel):
|
|
21
23
|
model_config = ConfigDict(extra='forbid', use_enum_values=True)
|
|
22
24
|
category: List[StoryCategory] = Field(...,min_length=1)
|
|
@@ -25,10 +27,10 @@ class StoryTags(BaseModel):
|
|
|
25
27
|
|
|
26
28
|
# enrichment
|
|
27
29
|
mitre_attack_enrichments: Optional[List[MitreAttackEnrichment]] = None
|
|
28
|
-
mitre_attack_tactics: Optional[Set[
|
|
30
|
+
mitre_attack_tactics: Optional[Set[MITRE_ATTACK_ID_TYPE]] = None
|
|
29
31
|
datamodels: Optional[Set[DataModel]] = None
|
|
30
32
|
kill_chain_phases: Optional[Set[KillChainPhase]] = None
|
|
31
|
-
cve: List[
|
|
33
|
+
cve: List[CVE_TYPE] = []
|
|
32
34
|
group: List[str] = Field([], description="A list of groups who leverage the techniques list in this Analytic Story.")
|
|
33
35
|
|
|
34
36
|
def getCategory_conf(self) -> str:
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
from pydantic import BaseModel, HttpUrl, FilePath, Field
|
|
3
|
-
from typing import Union, Optional
|
|
4
3
|
|
|
5
4
|
|
|
6
|
-
class
|
|
7
|
-
data:
|
|
5
|
+
class TestAttackData(BaseModel):
|
|
6
|
+
data: HttpUrl | FilePath = Field(...)
|
|
8
7
|
# TODO - should source and sourcetype should be mapped to a list
|
|
9
8
|
# of supported source and sourcetypes in a given environment?
|
|
10
9
|
source: str = Field(...)
|
|
11
10
|
sourcetype: str = Field(...)
|
|
12
|
-
custom_index:
|
|
13
|
-
host:
|
|
11
|
+
custom_index: str | None = None
|
|
12
|
+
host: str | None = None
|
contentctl/objects/test_group.py
CHANGED
|
@@ -2,14 +2,14 @@ from pydantic import BaseModel
|
|
|
2
2
|
|
|
3
3
|
from contentctl.objects.unit_test import UnitTest
|
|
4
4
|
from contentctl.objects.integration_test import IntegrationTest
|
|
5
|
-
from contentctl.objects.
|
|
5
|
+
from contentctl.objects.test_attack_data import TestAttackData
|
|
6
6
|
from contentctl.objects.base_test_result import TestResultStatus
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
class TestGroup(BaseModel):
|
|
10
10
|
"""
|
|
11
11
|
Groups of different types of tests relying on the same attack data
|
|
12
|
-
:param name: Name of the TestGroup (typically derived from a unit test as
|
|
12
|
+
:param name: Name of the TestGroup (typically derived from a unit test as
|
|
13
13
|
"{detection.name}:{test.name}")
|
|
14
14
|
:param unit_test: a UnitTest
|
|
15
15
|
:param integration_test: an IntegrationTest
|
|
@@ -18,7 +18,7 @@ class TestGroup(BaseModel):
|
|
|
18
18
|
name: str
|
|
19
19
|
unit_test: UnitTest
|
|
20
20
|
integration_test: IntegrationTest
|
|
21
|
-
attack_data: list[
|
|
21
|
+
attack_data: list[TestAttackData]
|
|
22
22
|
|
|
23
23
|
@classmethod
|
|
24
24
|
def derive_from_unit_test(cls, unit_test: UnitTest, name_prefix: str) -> "TestGroup":
|
contentctl/objects/unit_test.py
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from typing import Union
|
|
3
2
|
|
|
4
3
|
from pydantic import Field
|
|
5
4
|
|
|
6
5
|
from contentctl.objects.unit_test_baseline import UnitTestBaseline
|
|
7
|
-
from contentctl.objects.
|
|
6
|
+
from contentctl.objects.test_attack_data import TestAttackData
|
|
8
7
|
from contentctl.objects.unit_test_result import UnitTestResult
|
|
9
8
|
from contentctl.objects.base_test import BaseTest, TestType
|
|
10
9
|
from contentctl.objects.base_test_result import TestResultStatus
|
|
@@ -17,19 +16,13 @@ class UnitTest(BaseTest):
|
|
|
17
16
|
# contentType: SecurityContentType = SecurityContentType.unit_tests
|
|
18
17
|
|
|
19
18
|
# The test type (unit)
|
|
20
|
-
test_type: TestType = Field(TestType.UNIT)
|
|
21
|
-
|
|
22
|
-
# The condition to check if the search was successful
|
|
23
|
-
pass_condition: Union[str, None] = None
|
|
24
|
-
|
|
25
|
-
# Baselines to be run before a unit test
|
|
26
|
-
baselines: list[UnitTestBaseline] = []
|
|
19
|
+
test_type: TestType = Field(default=TestType.UNIT)
|
|
27
20
|
|
|
28
21
|
# The attack data to be ingested for the unit test
|
|
29
|
-
attack_data: list[
|
|
22
|
+
attack_data: list[TestAttackData]
|
|
30
23
|
|
|
31
24
|
# The result of the unit test
|
|
32
|
-
result:
|
|
25
|
+
result: UnitTestResult | None = None
|
|
33
26
|
|
|
34
27
|
def skip(self, message: str) -> None:
|
|
35
28
|
"""
|
|
@@ -59,7 +59,7 @@ dispatch.latest_time = {{ detection.deployment.scheduling.latest_time }}
|
|
|
59
59
|
action.correlationsearch.enabled = 1
|
|
60
60
|
action.correlationsearch.label = {{APP_NAME}} - {{ detection.name }} - Rule
|
|
61
61
|
action.correlationsearch.annotations = {{ detection.annotations | tojson }}
|
|
62
|
-
action.correlationsearch.metadata = {{ detection.
|
|
62
|
+
action.correlationsearch.metadata = {{ detection.metadata | tojson }}
|
|
63
63
|
{% if detection.deployment.scheduling.schedule_window is defined %}
|
|
64
64
|
schedule_window = {{ detection.deployment.scheduling.schedule_window }}
|
|
65
65
|
{% endif %}
|