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.
Files changed (36) hide show
  1. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +0 -6
  2. contentctl/actions/initialize.py +28 -12
  3. contentctl/actions/inspect.py +189 -91
  4. contentctl/actions/validate.py +3 -7
  5. contentctl/api.py +1 -1
  6. contentctl/contentctl.py +3 -0
  7. contentctl/enrichments/attack_enrichment.py +51 -82
  8. contentctl/enrichments/cve_enrichment.py +2 -2
  9. contentctl/helper/splunk_app.py +141 -10
  10. contentctl/input/director.py +5 -12
  11. contentctl/objects/abstract_security_content_objects/detection_abstract.py +11 -8
  12. contentctl/objects/annotated_types.py +6 -0
  13. contentctl/objects/atomic.py +51 -77
  14. contentctl/objects/config.py +145 -22
  15. contentctl/objects/constants.py +4 -1
  16. contentctl/objects/correlation_search.py +35 -28
  17. contentctl/objects/detection_metadata.py +71 -0
  18. contentctl/objects/detection_stanza.py +79 -0
  19. contentctl/objects/detection_tags.py +11 -9
  20. contentctl/objects/enums.py +0 -2
  21. contentctl/objects/errors.py +187 -0
  22. contentctl/objects/mitre_attack_enrichment.py +2 -1
  23. contentctl/objects/risk_event.py +94 -76
  24. contentctl/objects/savedsearches_conf.py +196 -0
  25. contentctl/objects/story_tags.py +3 -3
  26. contentctl/output/conf_writer.py +4 -1
  27. contentctl/output/new_content_yml_output.py +4 -9
  28. {contentctl-4.3.3.dist-info → contentctl-4.3.5.dist-info}/METADATA +4 -4
  29. {contentctl-4.3.3.dist-info → contentctl-4.3.5.dist-info}/RECORD +32 -32
  30. contentctl/objects/ssa_detection.py +0 -157
  31. contentctl/objects/ssa_detection_tags.py +0 -138
  32. contentctl/objects/unit_test_old.py +0 -10
  33. contentctl/objects/unit_test_ssa.py +0 -31
  34. {contentctl-4.3.3.dist-info → contentctl-4.3.5.dist-info}/LICENSE.md +0 -0
  35. {contentctl-4.3.3.dist-info → contentctl-4.3.5.dist-info}/WHEEL +0 -0
  36. {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, Annotated, Union
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[Annotated[str, Field(pattern=r"^T\d{4}(.\d{3})?$")]] = []
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[Annotated[str, r"^CVE-[1|2]\d{3}-\d+$"]] = []
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
- all_tests: None | List[AtomicTest] = output_dto.atomic_tests
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(AtomicTest.getAtomicByAtomicGuid(atomic_guid, all_tests))
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.: {[str(guid) for guid in missing_tests]}"
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
- print(missing_tests_string)
281
+ raise ValueError(missing_tests_string)
280
282
 
281
283
  return matched_tests + [AtomicTest.AtomicTestWhenTestIsMissing(test) for test in missing_tests]
@@ -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
 
@@ -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: Annotated[str, Field(pattern=r"^T\d{4}(.\d{3})?$")] = Field(...)
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(...)
@@ -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