contentctl 4.4.7__py3-none-any.whl → 5.0.0__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 (123) hide show
  1. contentctl/__init__.py +1 -1
  2. contentctl/actions/build.py +102 -57
  3. contentctl/actions/deploy_acs.py +29 -24
  4. contentctl/actions/detection_testing/DetectionTestingManager.py +66 -42
  5. contentctl/actions/detection_testing/GitService.py +134 -76
  6. contentctl/actions/detection_testing/generate_detection_coverage_badge.py +48 -30
  7. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +192 -147
  8. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +45 -32
  9. contentctl/actions/detection_testing/progress_bar.py +9 -6
  10. contentctl/actions/detection_testing/views/DetectionTestingView.py +16 -19
  11. contentctl/actions/detection_testing/views/DetectionTestingViewCLI.py +1 -5
  12. contentctl/actions/detection_testing/views/DetectionTestingViewFile.py +2 -2
  13. contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py +1 -4
  14. contentctl/actions/doc_gen.py +9 -5
  15. contentctl/actions/initialize.py +45 -33
  16. contentctl/actions/inspect.py +118 -61
  17. contentctl/actions/new_content.py +155 -108
  18. contentctl/actions/release_notes.py +276 -146
  19. contentctl/actions/reporting.py +23 -19
  20. contentctl/actions/test.py +33 -28
  21. contentctl/actions/validate.py +55 -34
  22. contentctl/api.py +54 -45
  23. contentctl/contentctl.py +124 -90
  24. contentctl/enrichments/attack_enrichment.py +112 -72
  25. contentctl/enrichments/cve_enrichment.py +34 -28
  26. contentctl/enrichments/splunk_app_enrichment.py +38 -36
  27. contentctl/helper/link_validator.py +101 -78
  28. contentctl/helper/splunk_app.py +69 -41
  29. contentctl/helper/utils.py +58 -53
  30. contentctl/input/director.py +68 -36
  31. contentctl/input/new_content_questions.py +27 -35
  32. contentctl/input/yml_reader.py +28 -18
  33. contentctl/objects/abstract_security_content_objects/detection_abstract.py +303 -259
  34. contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +115 -52
  35. contentctl/objects/alert_action.py +10 -9
  36. contentctl/objects/annotated_types.py +1 -1
  37. contentctl/objects/atomic.py +65 -54
  38. contentctl/objects/base_test.py +5 -3
  39. contentctl/objects/base_test_result.py +19 -11
  40. contentctl/objects/baseline.py +62 -30
  41. contentctl/objects/baseline_tags.py +30 -24
  42. contentctl/objects/config.py +790 -597
  43. contentctl/objects/constants.py +33 -56
  44. contentctl/objects/correlation_search.py +150 -136
  45. contentctl/objects/dashboard.py +55 -41
  46. contentctl/objects/data_source.py +16 -17
  47. contentctl/objects/deployment.py +43 -44
  48. contentctl/objects/deployment_email.py +3 -2
  49. contentctl/objects/deployment_notable.py +4 -2
  50. contentctl/objects/deployment_phantom.py +7 -6
  51. contentctl/objects/deployment_rba.py +3 -2
  52. contentctl/objects/deployment_scheduling.py +3 -2
  53. contentctl/objects/deployment_slack.py +3 -2
  54. contentctl/objects/detection.py +5 -2
  55. contentctl/objects/detection_metadata.py +1 -0
  56. contentctl/objects/detection_stanza.py +7 -2
  57. contentctl/objects/detection_tags.py +58 -103
  58. contentctl/objects/drilldown.py +66 -34
  59. contentctl/objects/enums.py +81 -100
  60. contentctl/objects/errors.py +16 -24
  61. contentctl/objects/integration_test.py +3 -3
  62. contentctl/objects/integration_test_result.py +1 -0
  63. contentctl/objects/investigation.py +59 -36
  64. contentctl/objects/investigation_tags.py +30 -19
  65. contentctl/objects/lookup.py +304 -101
  66. contentctl/objects/macro.py +55 -39
  67. contentctl/objects/manual_test.py +3 -3
  68. contentctl/objects/manual_test_result.py +1 -0
  69. contentctl/objects/mitre_attack_enrichment.py +17 -16
  70. contentctl/objects/notable_action.py +2 -1
  71. contentctl/objects/notable_event.py +1 -3
  72. contentctl/objects/playbook.py +37 -35
  73. contentctl/objects/playbook_tags.py +23 -13
  74. contentctl/objects/rba.py +96 -0
  75. contentctl/objects/risk_analysis_action.py +15 -11
  76. contentctl/objects/risk_event.py +110 -160
  77. contentctl/objects/risk_object.py +1 -0
  78. contentctl/objects/savedsearches_conf.py +9 -7
  79. contentctl/objects/security_content_object.py +5 -2
  80. contentctl/objects/story.py +54 -49
  81. contentctl/objects/story_tags.py +56 -45
  82. contentctl/objects/test_attack_data.py +2 -1
  83. contentctl/objects/test_group.py +5 -2
  84. contentctl/objects/threat_object.py +1 -0
  85. contentctl/objects/throttling.py +27 -18
  86. contentctl/objects/unit_test.py +3 -4
  87. contentctl/objects/unit_test_baseline.py +5 -5
  88. contentctl/objects/unit_test_result.py +6 -6
  89. contentctl/output/api_json_output.py +233 -220
  90. contentctl/output/attack_nav_output.py +21 -21
  91. contentctl/output/attack_nav_writer.py +29 -37
  92. contentctl/output/conf_output.py +235 -172
  93. contentctl/output/conf_writer.py +201 -125
  94. contentctl/output/data_source_writer.py +38 -26
  95. contentctl/output/doc_md_output.py +53 -27
  96. contentctl/output/jinja_writer.py +19 -15
  97. contentctl/output/json_writer.py +21 -11
  98. contentctl/output/svg_output.py +56 -38
  99. contentctl/output/templates/analyticstories_detections.j2 +2 -2
  100. contentctl/output/templates/analyticstories_stories.j2 +1 -1
  101. contentctl/output/templates/collections.j2 +1 -1
  102. contentctl/output/templates/doc_detections.j2 +0 -5
  103. contentctl/output/templates/es_investigations_investigations.j2 +1 -1
  104. contentctl/output/templates/es_investigations_stories.j2 +1 -1
  105. contentctl/output/templates/savedsearches_baselines.j2 +2 -2
  106. contentctl/output/templates/savedsearches_detections.j2 +10 -11
  107. contentctl/output/templates/savedsearches_investigations.j2 +2 -2
  108. contentctl/output/templates/transforms.j2 +6 -8
  109. contentctl/output/yml_writer.py +29 -20
  110. contentctl/templates/detections/endpoint/anomalous_usage_of_7zip.yml +16 -34
  111. contentctl/templates/stories/cobalt_strike.yml +1 -0
  112. {contentctl-4.4.7.dist-info → contentctl-5.0.0.dist-info}/METADATA +5 -4
  113. contentctl-5.0.0.dist-info/RECORD +168 -0
  114. {contentctl-4.4.7.dist-info → contentctl-5.0.0.dist-info}/WHEEL +1 -1
  115. contentctl/actions/initialize_old.py +0 -245
  116. contentctl/objects/event_source.py +0 -11
  117. contentctl/objects/observable.py +0 -37
  118. contentctl/output/detection_writer.py +0 -28
  119. contentctl/output/new_content_yml_output.py +0 -56
  120. contentctl/output/yml_output.py +0 -66
  121. contentctl-4.4.7.dist-info/RECORD +0 -173
  122. {contentctl-4.4.7.dist-info → contentctl-5.0.0.dist-info}/LICENSE.md +0 -0
  123. {contentctl-4.4.7.dist-info → contentctl-5.0.0.dist-info}/entry_points.txt +0 -0
@@ -1,51 +1,18 @@
1
1
  import re
2
2
  from functools import cached_property
3
3
 
4
- from pydantic import ConfigDict, BaseModel, Field, PrivateAttr, field_validator, computed_field
5
- from contentctl.objects.errors import ValidationFailed
4
+ from pydantic import (
5
+ BaseModel,
6
+ ConfigDict,
7
+ Field,
8
+ PrivateAttr,
9
+ computed_field,
10
+ field_validator,
11
+ )
12
+
6
13
  from contentctl.objects.detection import Detection
7
- from contentctl.objects.observable import Observable
8
-
9
- # TODO (#259): Map our observable types to more than user/system
10
- # TODO (#247): centralize this mapping w/ usage of SES_OBSERVABLE_TYPE_MAPPING (see
11
- # observable.py) and the ad hoc mapping made in detection_abstract.py (see the risk property func)
12
- TYPE_MAP: dict[str, list[str]] = {
13
- "system": [
14
- "Hostname",
15
- "IP Address",
16
- "Endpoint"
17
- ],
18
- "user": [
19
- "User",
20
- "User Name",
21
- "Email Address",
22
- "Email"
23
- ],
24
- "hash_values": [],
25
- "network_artifacts": [],
26
- "host_artifacts": [],
27
- "tools": [],
28
- "other": [
29
- "Process",
30
- "URL String",
31
- "Unknown",
32
- "Process Name",
33
- "MAC Address",
34
- "File Name",
35
- "File Hash",
36
- "Resource UID",
37
- "Uniform Resource Locator",
38
- "File",
39
- "Geo Location",
40
- "Container",
41
- "Registry Key",
42
- "Registry Value",
43
- "Other"
44
- ]
45
- }
46
-
47
- # Roles that should not generate risks
48
- IGNORE_ROLES: list[str] = ["Attacker"]
14
+ from contentctl.objects.errors import ValidationFailed
15
+ from contentctl.objects.rba import RiskObject
49
16
 
50
17
 
51
18
  class RiskEvent(BaseModel):
@@ -55,10 +22,12 @@ class RiskEvent(BaseModel):
55
22
  search_name: str
56
23
 
57
24
  # The subject of the risk event (e.g. a username, process name, system name, account ID, etc.)
58
- risk_object: int | str
25
+ # (not to be confused w/ the risk object from the detection)
26
+ es_risk_object: int | str = Field(alias="risk_object")
59
27
 
60
- # The type of the risk object (e.g. user, system, or other)
61
- risk_object_type: str
28
+ # The type of the risk object from ES (e.g. user, system, or other) (not to be confused w/
29
+ # the risk object from the detection)
30
+ es_risk_object_type: str = Field(alias="risk_object_type")
62
31
 
63
32
  # The level of risk associated w/ the risk event
64
33
  risk_score: int
@@ -74,22 +43,19 @@ class RiskEvent(BaseModel):
74
43
 
75
44
  # The MITRE ATT&CK IDs
76
45
  annotations_mitre_attack: list[str] = Field(
77
- alias="annotations.mitre_attack",
78
- default=[]
46
+ alias="annotations.mitre_attack", default=[]
79
47
  )
80
48
 
81
49
  # Contributing events search query (we use this to derive the corresponding field from the
82
- # observables)
50
+ # detection's risk object definition)
83
51
  contributing_events_search: str
84
52
 
85
- # Private attribute caching the observable this RiskEvent is mapped to
86
- _matched_observable: Observable | None = PrivateAttr(default=None)
53
+ # Private attribute caching the risk object this RiskEvent is mapped to
54
+ _matched_risk_object: RiskObject | None = PrivateAttr(default=None)
87
55
 
88
56
  # Allowing fields that aren't explicitly defined to be passed since some of the risk event's
89
57
  # fields vary depending on the SPL which generated them
90
- model_config = ConfigDict(
91
- extra="allow"
92
- )
58
+ model_config = ConfigDict(extra="allow")
93
59
 
94
60
  @field_validator("annotations_mitre_attack", "analyticstories", mode="before")
95
61
  @classmethod
@@ -108,10 +74,12 @@ class RiskEvent(BaseModel):
108
74
  def source_field_name(self) -> str:
109
75
  """
110
76
  A cached derivation of the source field name the risk event corresponds to in the relevant
111
- event(s). Useful for mapping back to an observable in the detection.
77
+ event(s). Useful for mapping back to a risk object in the detection.
112
78
  """
113
79
  pattern = re.compile(
114
- r"\| savedsearch \"" + self.search_name + r"\" \| search (?P<field>[^=]+)=.+"
80
+ r"\| savedsearch \""
81
+ + self.search_name
82
+ + r"\" \| search (?P<field>[^=]+)=.+"
115
83
  )
116
84
  match = pattern.search(self.contributing_events_search)
117
85
  if match is None:
@@ -128,13 +96,6 @@ class RiskEvent(BaseModel):
128
96
  :param detection: the detection associated w/ this risk event
129
97
  :raises: ValidationFailed
130
98
  """
131
- # Check risk_score
132
- if self.risk_score != detection.tags.risk_score:
133
- raise ValidationFailed(
134
- f"Risk score observed in risk event ({self.risk_score}) does not match risk score in "
135
- f"detection ({detection.tags.risk_score})."
136
- )
137
-
138
99
  # Check analyticstories
139
100
  self.validate_analyticstories(detection)
140
101
 
@@ -151,8 +112,15 @@ class RiskEvent(BaseModel):
151
112
  # Check risk_message
152
113
  self.validate_risk_message(detection)
153
114
 
154
- # Check several conditions against the observables
155
- self.validate_risk_against_observables(detection.tags.observable)
115
+ # Ensure the rba object is defined
116
+ if detection.rba is None:
117
+ raise ValidationFailed(
118
+ f"Unexpected error: Detection '{detection.name}' has no RBA objects associated "
119
+ "with it; cannot validate."
120
+ )
121
+
122
+ # Check several conditions against the detection's risk objects
123
+ self.validate_risk_against_risk_objects(detection.rba.risk_objects)
156
124
 
157
125
  def validate_mitre_ids(self, detection: Detection) -> None:
158
126
  """
@@ -160,7 +128,9 @@ class RiskEvent(BaseModel):
160
128
  :param detection: the detection associated w/ this risk event
161
129
  :raises: ValidationFailed
162
130
  """
163
- if sorted(self.annotations_mitre_attack) != sorted(detection.tags.mitre_attack_id):
131
+ if sorted(self.annotations_mitre_attack) != sorted(
132
+ detection.tags.mitre_attack_id
133
+ ):
164
134
  raise ValidationFailed(
165
135
  f"MITRE ATT&CK IDs in risk event ({self.annotations_mitre_attack}) do not match those"
166
136
  f" in detection ({detection.tags.mitre_attack_id})."
@@ -173,11 +143,13 @@ class RiskEvent(BaseModel):
173
143
  :raises: ValidationFailed
174
144
  """
175
145
  # Render the detection analytic_story to a list of strings before comparing
176
- detection_analytic_story = [story.name for story in detection.tags.analytic_story]
146
+ detection_analytic_story = [
147
+ story.name for story in detection.tags.analytic_story
148
+ ]
177
149
  if sorted(self.analyticstories) != sorted(detection_analytic_story):
178
150
  raise ValidationFailed(
179
151
  f"Analytic stories in risk event ({self.analyticstories}) do not match those"
180
- f" in detection ({detection.tags.analytic_story})."
152
+ f" in detection ({[x.name for x in detection.tags.analytic_story]})."
181
153
  )
182
154
 
183
155
  def validate_risk_message(self, detection: Detection) -> None:
@@ -186,10 +158,20 @@ class RiskEvent(BaseModel):
186
158
  :param detection: the detection associated w/ this risk event
187
159
  :raises: ValidationFailed
188
160
  """
161
+ # Ensure the rba object is defined
162
+ if detection.rba is None:
163
+ raise ValidationFailed(
164
+ f"Unexpected error: Detection '{detection.name}' has no RBA objects associated "
165
+ "with it; cannot validate."
166
+ )
167
+
189
168
  # Extract the field replacement tokens ("$...$")
190
169
  field_replacement_pattern = re.compile(r"\$\S+\$")
191
- tokens = field_replacement_pattern.findall(detection.tags.message)
170
+ tokens = field_replacement_pattern.findall(detection.rba.message)
192
171
 
172
+ # TODO (#346): could expand this to get the field values from the raw events and check
173
+ # to see that allexpected strings ARE in the risk message (as opposed to checking only
174
+ # that unexpected strings aren't)
193
175
  # Check for the presence of each token in the message from the risk event
194
176
  for token in tokens:
195
177
  if token in self.risk_message:
@@ -203,16 +185,12 @@ class RiskEvent(BaseModel):
203
185
  # placeholder
204
186
  tmp_placeholder = "PLACEHOLDERPATTERNFORESCAPING"
205
187
  escaped_source_message_with_placeholder: str = re.escape(
206
- field_replacement_pattern.sub(
207
- tmp_placeholder,
208
- detection.tags.message
209
- )
188
+ field_replacement_pattern.sub(tmp_placeholder, detection.rba.message)
210
189
  )
211
190
  placeholder_replacement_pattern = re.compile(tmp_placeholder)
212
191
  final_risk_message_pattern = re.compile(
213
192
  placeholder_replacement_pattern.sub(
214
- r"[\\s\\S]*\\S[\\s\\S]*",
215
- escaped_source_message_with_placeholder
193
+ r"[\\s\\S]*\\S[\\s\\S]*", escaped_source_message_with_placeholder
216
194
  )
217
195
  )
218
196
 
@@ -220,115 +198,87 @@ class RiskEvent(BaseModel):
220
198
  if final_risk_message_pattern.match(self.risk_message) is None:
221
199
  raise ValidationFailed(
222
200
  "Risk message in event does not match the pattern set by the detection. Message in "
223
- f"risk event: \"{self.risk_message}\". Message in detection: "
224
- f"\"{detection.tags.message}\"."
201
+ f'risk event: "{self.risk_message}". Message in detection: '
202
+ f'"{detection.rba.message}".'
225
203
  )
226
204
 
227
- def validate_risk_against_observables(self, observables: list[Observable]) -> None:
205
+ def validate_risk_against_risk_objects(self, risk_objects: set[RiskObject]) -> None:
228
206
  """
229
- Given the observables from the associated detection, validate the risk event against those
230
- observables
231
- :param observables: the Observable objects from the detection
207
+ Given the risk objects from the associated detection, validate the risk event against those
208
+ risk objects
209
+ :param risk_objects: the risk objects from the detection
232
210
  :raises: ValidationFailed
233
211
  """
234
- # Get the matched observable; will raise validation errors if no match can be made or if
235
- # risk is missing values associated w/ observables
236
- matched_observable = self.get_matched_observable(observables)
212
+ # Get the matched risk object; will raise validation errors if no match can be made or if
213
+ # risk is missing values associated w/ risk objects
214
+ matched_risk_object = self.get_matched_risk_object(risk_objects)
237
215
 
238
- # The risk object type should match our mapping of observable types to risk types
239
- expected_type = RiskEvent.observable_type_to_risk_type(matched_observable.type)
240
- if self.risk_object_type != expected_type:
216
+ # The risk object type from the risk event should match our mapping of internal risk object
217
+ # types
218
+ if self.es_risk_object_type != matched_risk_object.type.value:
241
219
  raise ValidationFailed(
242
- f"The risk object type ({self.risk_object_type}) does not match the expected type "
243
- f"based on the matched observable ({matched_observable.type}->{expected_type}): "
244
- f"risk=(object={self.risk_object}, type={self.risk_object_type}, "
245
- f"source_field_name={self.source_field_name}), "
246
- f"observable=(name={matched_observable.name}, type={matched_observable.type}, "
247
- f"role={matched_observable.role})"
220
+ f"The risk object type from the risk event ({self.es_risk_object_type}) does not match"
221
+ " the expected type based on the matched risk object "
222
+ f"({matched_risk_object.type.value}): risk event=(object={self.es_risk_object}, "
223
+ f"type={self.es_risk_object_type}, source_field_name={self.source_field_name}), "
224
+ f"risk object=(name={matched_risk_object.field}, "
225
+ f"type={matched_risk_object.type.value})"
248
226
  )
249
227
 
250
- @staticmethod
251
- def observable_type_to_risk_type(observable_type: str) -> str:
252
- """
253
- Given a string representing the observable type, use our mapping to convert it to the
254
- expected type in the risk event
255
- :param observable_type: the type of the observable
256
- :returns: a string (the risk object type)
257
- :raises ValueError: if the observable type has not yet been mapped to a risk object type
258
- """
259
- # Iterate over the map and search the lists for a match
260
- for risk_type in TYPE_MAP:
261
- if observable_type in TYPE_MAP[risk_type]:
262
- return risk_type
263
-
264
- raise ValueError(
265
- f"Observable type {observable_type} does not have a mapping to a risk type in TYPE_MAP"
266
- )
228
+ # Check risk_score
229
+ if self.risk_score != matched_risk_object.score:
230
+ raise ValidationFailed(
231
+ f"Risk score observed in risk event ({self.risk_score}) does not match risk score in "
232
+ f"matched risk object from detection ({matched_risk_object.score})."
233
+ )
267
234
 
268
- @staticmethod
269
- def ignore_observable(observable: Observable) -> bool:
270
- """
271
- Given an observable, determine based on its roles if it should be ignored in risk/observable
272
- matching (e.g. Attacker role observables should not generate risk events)
273
- :param observable: the Observable object we are checking the roles of
274
- :returns: a bool indicating whether this observable should be ignored or not
275
- """
276
- ignore = False
277
- for role in observable.role:
278
- if role in IGNORE_ROLES:
279
- ignore = True
280
- break
281
- return ignore
282
-
283
- def get_matched_observable(self, observables: list[Observable]) -> Observable:
235
+ def get_matched_risk_object(self, risk_objects: set[RiskObject]) -> RiskObject:
284
236
  """
285
- Given a list of observables, return the one this risk event matches
286
- :param observables: the list of Observable objects we are checking against
287
- :returns: the matched Observable object
237
+ Given a set of risk objects, return the one this risk event matches
238
+ :param risk_objects: the list of risk objects we are checking against
239
+ :returns: the matched risk object
288
240
  :raises ValidationFailed: if a match could not be made or if an expected field (based on
289
- one of the observables) could not be found in the risk event
241
+ one of the risk objects) could not be found in the risk event
290
242
  """
291
243
  # Return the cached match if already found
292
- if self._matched_observable is not None:
293
- return self._matched_observable
244
+ if self._matched_risk_object is not None:
245
+ return self._matched_risk_object
294
246
 
295
- matched_observable: Observable | None = None
247
+ matched_risk_object: RiskObject | None = None
296
248
 
297
249
  # Iterate over the obervables and check for a match
298
- for observable in observables:
250
+ for risk_object in risk_objects:
299
251
  # TODO (#252): Refactor and re-enable per-field validation of risk events
300
- # Each the field name used in each observable shoud be present in the risk event
301
- # if not hasattr(self, observable.name):
252
+ # Each the field name used in each risk object shoud be present in the risk event
253
+ # if not hasattr(self, risk_object.field):
302
254
  # raise ValidationFailed(
303
- # f"Observable field \"{observable.name}\" not found in risk event."
255
+ # f"Risk object field \"{risk_object.field}\" not found in risk event."
304
256
  # )
305
257
 
306
- # Try to match the risk_object against a specific observable for the obervables with
307
- # a valid role (some, like Attacker, shouldn't get converted to risk events)
308
- if self.source_field_name == observable.name:
309
- if matched_observable is not None:
258
+ # Try to match the risk_object against a specific risk object
259
+ if self.source_field_name == risk_object.field:
260
+ # TODO (#347): enforce that field names are not repeated across risk objects as
261
+ # part of build/validate
262
+ if matched_risk_object is not None:
310
263
  raise ValueError(
311
- "Unexpected conditon: we don't expect the source event field "
312
- "corresponding to an observables field name to be repeated."
264
+ "Unexpected conditon: we don't expect multiple risk objects to use the "
265
+ "same field name, so we should not be able match the risk event to "
266
+ "multiple risk objects."
313
267
  )
314
268
 
315
- # Report any risk events we find that shouldn't be there
316
- if RiskEvent.ignore_observable(observable):
317
- raise ValidationFailed(
318
- "Risk event matched an observable with an invalid role: "
319
- f"(name={observable.name}, type={observable.type}, role={observable.role})")
320
- # NOTE: we explicitly do not break early as we want to check each observable
321
- matched_observable = observable
269
+ # NOTE: we explicitly do not break early as we want to check each risk object
270
+ matched_risk_object = risk_object
322
271
 
323
- # Ensure we were able to match the risk event to a specific observable
324
- if matched_observable is None:
272
+ # Ensure we were able to match the risk event to a specific risk object
273
+ if matched_risk_object is None:
325
274
  raise ValidationFailed(
326
- f"Unable to match risk event (object={self.risk_object}, type="
327
- f"{self.risk_object_type}, source_field_name={self.source_field_name}) to an "
328
- "observable; please check for errors in the observable roles/types for this "
329
- "detection, as well as the risk event build process in contentctl."
275
+ f"Unable to match risk event (object={self.es_risk_object}, type="
276
+ f"{self.es_risk_object_type}, source_field_name={self.source_field_name}) to a "
277
+ "risk object in the detection; please check for errors in the risk object types for this "
278
+ "detection, as well as the risk event build process in contentctl (e.g. threat "
279
+ "objects aren't being converted to risk objects somehow)."
330
280
  )
331
281
 
332
- # Cache and return the matched observable
333
- self._matched_observable = matched_observable
334
- return self._matched_observable
282
+ # Cache and return the matched risk object
283
+ self._matched_risk_object = matched_risk_object
284
+ return self._matched_risk_object
@@ -13,6 +13,7 @@ class RiskObject(BaseModel):
13
13
  :param type_: the type of the risk object (e.g. "system")
14
14
  :param score: the risk score associated with the obersevable (e.g. 64)
15
15
  """
16
+
16
17
  field: str
17
18
  type: str
18
19
  score: int
@@ -1,4 +1,3 @@
1
-
2
1
  from pathlib import Path
3
2
  from typing import Any, ClassVar
4
3
  import re
@@ -17,6 +16,7 @@ class SavedsearchesConf(BaseModel):
17
16
  NOTE: At present, this model only parses the detections themselves from the .conf; thing like
18
17
  baselines or response tasks are left alone currently
19
18
  """
19
+
20
20
  # The path to the conf file
21
21
  path: Path = Field(...)
22
22
 
@@ -112,8 +112,7 @@ class SavedsearchesConf(BaseModel):
112
112
 
113
113
  # Build the stanza model from the accumulated lines and adjust the state to end this section
114
114
  self.detection_stanzas[self._current_section_name] = DetectionStanza(
115
- name=self._current_section_name,
116
- lines=self._current_section_lines
115
+ name=self._current_section_name, lines=self._current_section_lines
117
116
  )
118
117
  self._in_section = False
119
118
 
@@ -170,7 +169,9 @@ class SavedsearchesConf(BaseModel):
170
169
  self._in_detections = True
171
170
 
172
171
  @staticmethod
173
- def init_from_package(package_path: Path, app_name: str, appid: str) -> "SavedsearchesConf":
172
+ def init_from_package(
173
+ package_path: Path, app_name: str, appid: str
174
+ ) -> "SavedsearchesConf":
174
175
  """
175
176
  Alternate constructor which can take an app package, and extract the savedsearches.conf from
176
177
  a temporary file.
@@ -188,9 +189,10 @@ class SavedsearchesConf(BaseModel):
188
189
  # Open the tar/gzip archive
189
190
  with tarfile.open(package_path) as package:
190
191
  # Extract the savedsearches.conf and use it to init the model
191
- package_conf_path = SavedsearchesConf.PACKAGE_CONF_PATH_FMT_STR.format(appid=appid)
192
+ package_conf_path = SavedsearchesConf.PACKAGE_CONF_PATH_FMT_STR.format(
193
+ appid=appid
194
+ )
192
195
  package.extract(package_conf_path, path=tmpdir)
193
196
  return SavedsearchesConf(
194
- path=Path(tmpdir, package_conf_path),
195
- app_label=app_name
197
+ path=Path(tmpdir, package_conf_path), app_label=app_name
196
198
  )
@@ -1,5 +1,8 @@
1
1
  from __future__ import annotations
2
- from contentctl.objects.abstract_security_content_objects.security_content_object_abstract import SecurityContentObject_Abstract
2
+ from contentctl.objects.abstract_security_content_objects.security_content_object_abstract import (
3
+ SecurityContentObject_Abstract,
4
+ )
5
+
3
6
 
4
7
  class SecurityContentObject(SecurityContentObject_Abstract):
5
- pass
8
+ pass
@@ -1,99 +1,106 @@
1
1
  from __future__ import annotations
2
- from typing import TYPE_CHECKING,List
3
- from contentctl.objects.story_tags import StoryTags
4
- from pydantic import Field, model_serializer,computed_field, model_validator
2
+
5
3
  import re
4
+ from typing import TYPE_CHECKING, List, Literal
5
+
6
+ from pydantic import Field, computed_field, model_serializer, model_validator
7
+
8
+ from contentctl.objects.story_tags import StoryTags
9
+
6
10
  if TYPE_CHECKING:
7
- from contentctl.objects.detection import Detection
8
- from contentctl.objects.investigation import Investigation
9
11
  from contentctl.objects.baseline import Baseline
10
- from contentctl.objects.data_source import DataSource
11
12
  from contentctl.objects.config import CustomApp
13
+ from contentctl.objects.data_source import DataSource
14
+ from contentctl.objects.detection import Detection
15
+ from contentctl.objects.investigation import Investigation
12
16
 
17
+ from contentctl.objects.enums import DetectionStatus
13
18
  from contentctl.objects.security_content_object import SecurityContentObject
14
19
 
20
+
15
21
  class Story(SecurityContentObject):
16
22
  narrative: str = Field(...)
17
23
  tags: StoryTags = Field(...)
18
-
24
+ status: Literal[DetectionStatus.production, DetectionStatus.deprecated]
19
25
  # These are updated when detection and investigation objects are created.
20
26
  # Specifically in the model_post_init functions
21
- detections:List[Detection] = []
27
+ detections: List[Detection] = []
22
28
  investigations: List[Investigation] = []
23
29
  baselines: List[Baseline] = []
24
-
25
-
30
+
26
31
  @computed_field
27
32
  @property
28
- def data_sources(self)-> list[DataSource]:
33
+ def data_sources(self) -> list[DataSource]:
29
34
  # Only add a data_source if it does not already exist in the story
30
- data_source_objects:set[DataSource] = set()
35
+ data_source_objects: set[DataSource] = set()
31
36
  for detection in self.detections:
32
37
  data_source_objects.update(set(detection.data_source_objects))
33
-
38
+
34
39
  return sorted(list(data_source_objects))
35
40
 
41
+ def storyAndInvestigationNamesWithApp(self, app: CustomApp) -> List[str]:
42
+ return [
43
+ detection.get_conf_stanza_name(app) for detection in self.detections
44
+ ] + [
45
+ investigation.get_response_task_name(app)
46
+ for investigation in self.investigations
47
+ ]
36
48
 
37
- def storyAndInvestigationNamesWithApp(self, app:CustomApp)->List[str]:
38
- return [detection.get_conf_stanza_name(app) for detection in self.detections] + \
39
- [investigation.get_response_task_name(app) for investigation in self.investigations]
40
-
41
49
  @model_serializer
42
50
  def serialize_model(self):
43
- #Call serializer for parent
51
+ # Call serializer for parent
44
52
  super_fields = super().serialize_model()
45
-
46
- #All fields custom to this model
47
- model= {
53
+
54
+ # All fields custom to this model
55
+ model = {
48
56
  "narrative": self.narrative,
49
57
  "tags": self.tags.model_dump(),
50
58
  "detection_names": self.detection_names,
51
59
  "investigation_names": self.investigation_names,
52
60
  "baseline_names": self.baseline_names,
53
61
  "author_company": self.author_company,
54
- "author_name":self.author_name
62
+ "author_name": self.author_name,
55
63
  }
56
64
  detections = []
57
65
  for detection in self.detections:
58
66
  new_detection = {
59
- "name":detection.name,
60
- "source":detection.source,
61
- "type":detection.type
67
+ "name": detection.name,
68
+ "source": detection.source,
69
+ "type": detection.type,
62
70
  }
63
71
  if self.tags.mitre_attack_enrichments is not None:
64
- new_detection['tags'] = {"mitre_attack_enrichments": [{"mitre_attack_technique": enrichment.mitre_attack_technique} for enrichment in detection.tags.mitre_attack_enrichments]}
72
+ new_detection["tags"] = {
73
+ "mitre_attack_enrichments": [
74
+ {"mitre_attack_technique": enrichment.mitre_attack_technique}
75
+ for enrichment in detection.tags.mitre_attack_enrichments
76
+ ]
77
+ }
65
78
  detections.append(new_detection)
66
79
 
67
- model['detections'] = detections
68
- #Combine fields from this model with fields from parent
80
+ model["detections"] = detections
81
+ # Combine fields from this model with fields from parent
69
82
  super_fields.update(model)
70
-
71
- #return the model
83
+
84
+ # return the model
72
85
  return super_fields
73
86
 
74
87
  @model_validator(mode="after")
75
88
  def setTagsFields(self):
76
-
77
89
  enrichments = []
78
90
  for detection in self.detections:
79
91
  enrichments.extend(detection.tags.mitre_attack_enrichments)
80
92
  self.tags.mitre_attack_enrichments = list(set(enrichments))
81
93
 
82
-
83
94
  tactics = []
84
95
  for enrichment in self.tags.mitre_attack_enrichments:
85
96
  tactics.extend(enrichment.mitre_attack_tactics)
86
97
  self.tags.mitre_attack_tactics = set(tactics)
87
98
 
88
-
89
-
90
99
  datamodels = []
91
100
  for detection in self.detections:
92
101
  datamodels.extend(detection.datamodel)
93
102
  self.tags.datamodels = set(datamodels)
94
103
 
95
-
96
-
97
104
  kill_chain_phases = []
98
105
  for detection in self.detections:
99
106
  kill_chain_phases.extend(detection.tags.kill_chain_phases)
@@ -101,42 +108,40 @@ class Story(SecurityContentObject):
101
108
 
102
109
  return self
103
110
 
104
-
105
111
  @computed_field
106
112
  @property
107
- def author_name(self)->str:
108
- match_author = re.search(r'^([^,]+)', self.author)
113
+ def author_name(self) -> str:
114
+ match_author = re.search(r"^([^,]+)", self.author)
109
115
  if match_author is None:
110
- return 'no'
116
+ return "no"
111
117
  else:
112
118
  return match_author.group(1)
113
119
 
114
120
  @computed_field
115
121
  @property
116
- def author_company(self)->str:
117
- match_company = re.search(r',\s?(.*)$', self.author)
122
+ def author_company(self) -> str:
123
+ match_company = re.search(r",\s?(.*)$", self.author)
118
124
  if match_company is None:
119
- return 'no'
125
+ return "no"
120
126
  else:
121
127
  return match_company.group(1)
122
128
 
123
129
  @computed_field
124
130
  @property
125
- def author_email(self)->str:
131
+ def author_email(self) -> str:
126
132
  return "-"
127
133
 
128
134
  @computed_field
129
135
  @property
130
- def detection_names(self)->List[str]:
136
+ def detection_names(self) -> List[str]:
131
137
  return [detection.name for detection in self.detections]
132
-
138
+
133
139
  @computed_field
134
140
  @property
135
- def investigation_names(self)->List[str]:
141
+ def investigation_names(self) -> List[str]:
136
142
  return [investigation.name for investigation in self.investigations]
137
143
 
138
144
  @computed_field
139
145
  @property
140
- def baseline_names(self)->List[str]:
146
+ def baseline_names(self) -> List[str]:
141
147
  return [baseline.name for baseline in self.baselines]
142
-