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.
Files changed (34) hide show
  1. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +35 -27
  2. contentctl/actions/detection_testing/views/DetectionTestingView.py +64 -38
  3. contentctl/actions/detection_testing/views/DetectionTestingViewCLI.py +1 -0
  4. contentctl/actions/detection_testing/views/DetectionTestingViewFile.py +3 -5
  5. contentctl/actions/test.py +55 -32
  6. contentctl/contentctl.py +3 -6
  7. contentctl/enrichments/attack_enrichment.py +2 -1
  8. contentctl/enrichments/cve_enrichment.py +2 -2
  9. contentctl/objects/abstract_security_content_objects/detection_abstract.py +183 -90
  10. contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +1 -0
  11. contentctl/objects/annotated_types.py +6 -0
  12. contentctl/objects/base_test.py +1 -0
  13. contentctl/objects/base_test_result.py +1 -0
  14. contentctl/objects/config.py +27 -12
  15. contentctl/objects/correlation_search.py +35 -28
  16. contentctl/objects/detection_tags.py +8 -3
  17. contentctl/objects/integration_test.py +3 -5
  18. contentctl/objects/integration_test_result.py +1 -5
  19. contentctl/objects/investigation.py +1 -0
  20. contentctl/objects/manual_test.py +32 -0
  21. contentctl/objects/manual_test_result.py +8 -0
  22. contentctl/objects/mitre_attack_enrichment.py +3 -1
  23. contentctl/objects/risk_event.py +94 -76
  24. contentctl/objects/ssa_detection.py +1 -0
  25. contentctl/objects/story_tags.py +5 -3
  26. contentctl/objects/{unit_test_attack_data.py → test_attack_data.py} +4 -5
  27. contentctl/objects/test_group.py +3 -3
  28. contentctl/objects/unit_test.py +4 -11
  29. contentctl/output/templates/savedsearches_detections.j2 +1 -1
  30. {contentctl-4.3.2.dist-info → contentctl-4.3.4.dist-info}/METADATA +8 -8
  31. {contentctl-4.3.2.dist-info → contentctl-4.3.4.dist-info}/RECORD +34 -31
  32. {contentctl-4.3.2.dist-info → contentctl-4.3.4.dist-info}/LICENSE.md +0 -0
  33. {contentctl-4.3.2.dist-info → contentctl-4.3.4.dist-info}/WHEEL +0 -0
  34. {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}" | head 1 | fields orig_sid] | tojson'
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}" | head 1 | fields orig_sid] | tojson'
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
- # observables = CorrelationSearch._get_relevant_observables(self.detection.tags.observable)
693
- # observable_counts: dict[str, int] = {str(x): 0 for x in observables}
694
- # if len(observables) != len(observable_counts):
695
- # raise ClientError(
696
- # f"At least two observables in '{self.detection.name}' have the same name."
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
- # matched_observable = event.get_matched_observable(self.detection.tags.observable)
717
- # self.logger.debug(
718
- # f"Matched risk event ({event.risk_object}, {event.risk_object_type}) to observable "
719
- # f"({matched_observable.name}, {matched_observable.type}, {matched_observable.role})"
720
- # )
721
- # observable_counts[str(matched_observable)] += 1
722
-
723
- # TODO (PEX-433): test my new contentctl logic against an old ESCU build; my logic should
724
- # detect the faulty attacker events -> this was the issue from the 4.28/4.27 release;
725
- # recreate by testing against one of those old builds w/ the bad config
726
- # TODO (PEX-433): Re-enable this check once we have refined the logic and reduced the false
727
- # positive
728
- # rate in risk/obseravble matching
729
- # TODO (PEX-433): I foresee issues here if for example a parent and child process share a
730
- # name (matched observable could be either) -> these issues are confirmed to exist, e.g.
731
- # `Windows Steal Authentication Certificates Export Certificate`
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[Annotated[str, Field(pattern=r"^T\d{4}(.\d{3})?$")]] = []
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[Annotated[str, r"^CVE-[1|2]\d{3}-\d+$"]] = []
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: Union[None, IntegrationTestResult] = None
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: Optional[int] = None
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
+ )
@@ -0,0 +1,8 @@
1
+ from contentctl.objects.base_test_result import BaseTestResult
2
+
3
+
4
+ class ManualTestResult(BaseTestResult):
5
+ """
6
+ A manual test result
7
+ """
8
+ pass
@@ -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: Annotated[str, Field(pattern=r"^T\d{4}(.\d{3})?$")] = Field(...)
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(...)
@@ -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 (PEX-433): use SES_OBSERVABLE_TYPE_MAPPING
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
- "user": ["User"],
13
- "system": ["Hostname", "IP Address", "Endpoint"],
14
- "other": ["Process", "URL String", "Unknown", "Process Name"],
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
- # TODO (PEX-433): use SES_OBSERVABLE_ROLE_MAPPING
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: Union[int, str]
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: Optional[Observable] = PrivateAttr(default=None)
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: Union[str, list[str]]) -> list[str]:
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
- # self.validate_risk_against_observables(detection.tags.observable)
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}=={expected_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: Optional[Observable] = None
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
- # TODO (PEX-433): this check is redundant I think; earlier in the unit test, observable
283
- # field
284
- # names are compared against the search result set, ensuring all are present; if all
285
- # are present in the result set, all are present in the risk event
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, don't get converted to risk events)
293
- if not RiskEvent.ignore_observable(observable):
294
- if self.matches_observable(observable):
295
- # TODO (PEX-433): This check fails as there are some instances where this is
296
- # true (e.g. we have an observable for process and parent_process and both
297
- # have the same name like "cmd.exe")
298
- if matched_observable is not None:
299
- raise ValueError(
300
- "Unexpected conditon: we don't expect the value corresponding to an "
301
- "observables field name to be repeated"
302
- )
303
- # NOTE: we explicitly do not break early as we want to check each observable
304
- matched_observable = observable
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}, {self.risk_object_type}) to an "
310
- "appropriate observable"
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
@@ -59,6 +59,7 @@ class SSADetection(BaseModel):
59
59
  # raise ValueError('name is longer then 67 chars: ' + v)
60
60
  # return v
61
61
 
62
+ # TODO (#266): disable the use_enum_values configuration
62
63
  class Config:
63
64
  use_enum_values = True
64
65
 
@@ -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[Annotated[str, Field(pattern=r"^T\d{4}(.\d{3})?$")]]] = None
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[Annotated[str, r"^CVE-[1|2]\d{3}-\d+$"]] = []
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 UnitTestAttackData(BaseModel):
7
- data: Union[HttpUrl, FilePath] = Field(...)
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: Optional[str] = None
13
- host: Optional[str] = None
11
+ custom_index: str | None = None
12
+ host: str | None = None
@@ -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.unit_test_attack_data import UnitTestAttackData
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[UnitTestAttackData]
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":
@@ -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.unit_test_attack_data import UnitTestAttackData
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[UnitTestAttackData]
22
+ attack_data: list[TestAttackData]
30
23
 
31
24
  # The result of the unit test
32
- result: Union[None, UnitTestResult] = None
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.getMetadata() | tojson }}
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 %}