contentctl 5.0.0a2__py3-none-any.whl → 5.0.0a3__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 (106) hide show
  1. contentctl/__init__.py +1 -1
  2. contentctl/actions/build.py +88 -55
  3. contentctl/actions/deploy_acs.py +29 -24
  4. contentctl/actions/detection_testing/DetectionTestingManager.py +66 -41
  5. contentctl/actions/detection_testing/GitService.py +2 -4
  6. contentctl/actions/detection_testing/generate_detection_coverage_badge.py +48 -30
  7. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +163 -124
  8. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +45 -32
  9. contentctl/actions/detection_testing/progress_bar.py +3 -0
  10. contentctl/actions/detection_testing/views/DetectionTestingView.py +15 -18
  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 +78 -50
  18. contentctl/actions/release_notes.py +276 -146
  19. contentctl/actions/reporting.py +23 -19
  20. contentctl/actions/test.py +31 -25
  21. contentctl/actions/validate.py +54 -34
  22. contentctl/api.py +54 -45
  23. contentctl/contentctl.py +10 -10
  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 -39
  30. contentctl/input/director.py +69 -37
  31. contentctl/input/new_content_questions.py +26 -34
  32. contentctl/input/yml_reader.py +22 -17
  33. contentctl/objects/abstract_security_content_objects/detection_abstract.py +250 -314
  34. contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +58 -36
  35. contentctl/objects/alert_action.py +8 -8
  36. contentctl/objects/annotated_types.py +1 -1
  37. contentctl/objects/atomic.py +64 -54
  38. contentctl/objects/base_test.py +2 -1
  39. contentctl/objects/base_test_result.py +16 -8
  40. contentctl/objects/baseline.py +41 -30
  41. contentctl/objects/baseline_tags.py +29 -22
  42. contentctl/objects/config.py +1 -1
  43. contentctl/objects/constants.py +29 -58
  44. contentctl/objects/correlation_search.py +75 -55
  45. contentctl/objects/dashboard.py +55 -41
  46. contentctl/objects/data_source.py +13 -13
  47. contentctl/objects/deployment.py +44 -37
  48. contentctl/objects/deployment_email.py +1 -1
  49. contentctl/objects/deployment_notable.py +2 -1
  50. contentctl/objects/deployment_phantom.py +5 -5
  51. contentctl/objects/deployment_rba.py +1 -1
  52. contentctl/objects/deployment_scheduling.py +1 -1
  53. contentctl/objects/deployment_slack.py +1 -1
  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 +54 -64
  58. contentctl/objects/drilldown.py +66 -35
  59. contentctl/objects/enums.py +61 -43
  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 +41 -26
  64. contentctl/objects/investigation_tags.py +29 -17
  65. contentctl/objects/lookup.py +234 -113
  66. contentctl/objects/macro.py +55 -38
  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 +22 -16
  74. contentctl/objects/rba.py +14 -8
  75. contentctl/objects/risk_analysis_action.py +15 -11
  76. contentctl/objects/risk_event.py +27 -20
  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 +45 -44
  81. contentctl/objects/story_tags.py +56 -44
  82. contentctl/objects/test_group.py +5 -2
  83. contentctl/objects/threat_object.py +1 -0
  84. contentctl/objects/throttling.py +27 -18
  85. contentctl/objects/unit_test.py +3 -4
  86. contentctl/objects/unit_test_baseline.py +4 -5
  87. contentctl/objects/unit_test_result.py +6 -6
  88. contentctl/output/api_json_output.py +22 -22
  89. contentctl/output/attack_nav_output.py +21 -21
  90. contentctl/output/attack_nav_writer.py +29 -37
  91. contentctl/output/conf_output.py +230 -174
  92. contentctl/output/data_source_writer.py +38 -25
  93. contentctl/output/doc_md_output.py +53 -27
  94. contentctl/output/jinja_writer.py +19 -15
  95. contentctl/output/json_writer.py +20 -8
  96. contentctl/output/svg_output.py +56 -38
  97. contentctl/output/templates/transforms.j2 +2 -2
  98. contentctl/output/yml_writer.py +18 -24
  99. {contentctl-5.0.0a2.dist-info → contentctl-5.0.0a3.dist-info}/METADATA +1 -1
  100. contentctl-5.0.0a3.dist-info/RECORD +168 -0
  101. contentctl/actions/initialize_old.py +0 -245
  102. contentctl/objects/observable.py +0 -39
  103. contentctl-5.0.0a2.dist-info/RECORD +0 -170
  104. {contentctl-5.0.0a2.dist-info → contentctl-5.0.0a3.dist-info}/LICENSE.md +0 -0
  105. {contentctl-5.0.0a2.dist-info → contentctl-5.0.0a3.dist-info}/WHEEL +0 -0
  106. {contentctl-5.0.0a2.dist-info → contentctl-5.0.0a3.dist-info}/entry_points.txt +0 -0
@@ -1,5 +1,11 @@
1
1
  from __future__ import annotations
2
- from pydantic import Field, computed_field,ValidationInfo, model_serializer, NonNegativeInt, ConfigDict
2
+ from pydantic import (
3
+ Field,
4
+ computed_field,
5
+ ValidationInfo,
6
+ model_serializer,
7
+ NonNegativeInt,
8
+ )
3
9
  from typing import Any
4
10
  import uuid
5
11
  import datetime
@@ -10,68 +16,69 @@ from contentctl.objects.alert_action import AlertAction
10
16
  from contentctl.objects.enums import DeploymentType
11
17
 
12
18
 
13
- class Deployment(SecurityContentObject):
19
+ class Deployment(SecurityContentObject):
14
20
  scheduling: DeploymentScheduling = Field(...)
15
21
  alert_action: AlertAction = AlertAction()
16
22
  type: DeploymentType = Field(...)
17
- author: str = Field(...,max_length=255)
23
+ author: str = Field(..., max_length=255)
18
24
  version: NonNegativeInt = 1
19
25
 
20
- #Type was the only tag exposed and should likely be removed/refactored.
21
- #For transitional reasons, provide this as a computed_field in prep for removal
26
+ # Type was the only tag exposed and should likely be removed/refactored.
27
+ # For transitional reasons, provide this as a computed_field in prep for removal
22
28
  @computed_field
23
29
  @property
24
- def tags(self)->dict[str,DeploymentType]:
30
+ def tags(self) -> dict[str, DeploymentType]:
25
31
  return {"type": self.type}
26
32
 
27
-
28
33
  @staticmethod
29
- def getDeployment(v:dict[str,Any], info:ValidationInfo)->Deployment:
34
+ def getDeployment(v: dict[str, Any], info: ValidationInfo) -> Deployment:
30
35
  if v != {}:
31
36
  # If the user has defined a deployment, then allow it to be validated
32
37
  # and override the default deployment info defined in type:Baseline
33
- v['type'] = DeploymentType.Embedded
34
-
38
+ v["type"] = DeploymentType.Embedded
39
+
35
40
  detection_name = info.data.get("name", None)
36
41
  if detection_name is None:
37
- raise ValueError("Could not create inline deployment - Baseline or Detection lacking 'name' field,")
42
+ raise ValueError(
43
+ "Could not create inline deployment - Baseline or Detection lacking 'name' field,"
44
+ )
38
45
 
39
- # Add a number of static values
40
- v.update({
41
- 'name': f"{detection_name} - Inline Deployment",
42
- 'id':uuid.uuid4(),
43
- 'date': datetime.date.today(),
44
- 'description': "Inline deployment created at runtime.",
45
- 'author': "contentctl tool"
46
- })
46
+ # Add a number of static values
47
+ v.update(
48
+ {
49
+ "name": f"{detection_name} - Inline Deployment",
50
+ "id": uuid.uuid4(),
51
+ "date": datetime.date.today(),
52
+ "description": "Inline deployment created at runtime.",
53
+ "author": "contentctl tool",
54
+ }
55
+ )
47
56
 
48
-
49
57
  # This constructs a temporary in-memory deployment,
50
- # allowing the deployment to be easily defined in the
58
+ # allowing the deployment to be easily defined in the
51
59
  # detection on a per detection basis.
52
60
  return Deployment.model_validate(v)
53
-
61
+
54
62
  else:
55
- return SecurityContentObject.getDeploymentFromType(info.data.get("type",None), info)
56
-
63
+ return SecurityContentObject.getDeploymentFromType(
64
+ info.data.get("type", None), info
65
+ )
66
+
57
67
  @model_serializer
58
68
  def serialize_model(self):
59
- #Call serializer for parent
69
+ # Call serializer for parent
60
70
  super_fields = super().serialize_model()
61
-
62
- #All fields custom to this model
63
- model= {
64
- "scheduling": self.scheduling.model_dump(),
65
- "tags": self.tags
66
- }
67
71
 
68
- #Combine fields from this model with fields from parent
72
+ # All fields custom to this model
73
+ model = {"scheduling": self.scheduling.model_dump(), "tags": self.tags}
74
+
75
+ # Combine fields from this model with fields from parent
69
76
  model.update(super_fields)
70
-
77
+
71
78
  alert_action_fields = self.alert_action.model_dump()
72
79
  model.update(alert_action_fields)
73
80
 
74
- del(model['references'])
75
-
76
- #return the model
77
- return model
81
+ del model["references"]
82
+
83
+ # return the model
84
+ return model
@@ -6,4 +6,4 @@ class DeploymentEmail(BaseModel):
6
6
  model_config = ConfigDict(extra="forbid")
7
7
  message: str
8
8
  subject: str
9
- to: str
9
+ to: str
@@ -2,8 +2,9 @@ from __future__ import annotations
2
2
  from pydantic import BaseModel, ConfigDict
3
3
  from typing import List
4
4
 
5
+
5
6
  class DeploymentNotable(BaseModel):
6
7
  model_config = ConfigDict(extra="forbid")
7
8
  rule_description: str
8
9
  rule_title: str
9
- nes_fields: List[str]
10
+ nes_fields: List[str]
@@ -4,8 +4,8 @@ from pydantic import BaseModel, ConfigDict
4
4
 
5
5
  class DeploymentPhantom(BaseModel):
6
6
  model_config = ConfigDict(extra="forbid")
7
- cam_workers : str
8
- label : str
9
- phantom_server : str
10
- sensitivity : str
11
- severity : str
7
+ cam_workers: str
8
+ label: str
9
+ phantom_server: str
10
+ sensitivity: str
11
+ severity: str
@@ -4,4 +4,4 @@ from pydantic import BaseModel, ConfigDict
4
4
 
5
5
  class DeploymentRBA(BaseModel):
6
6
  model_config = ConfigDict(extra="forbid")
7
- enabled: bool = False
7
+ enabled: bool = False
@@ -7,4 +7,4 @@ class DeploymentScheduling(BaseModel):
7
7
  cron_schedule: str
8
8
  earliest_time: str
9
9
  latest_time: str
10
- schedule_window: str
10
+ schedule_window: str
@@ -5,4 +5,4 @@ from pydantic import BaseModel, ConfigDict
5
5
  class DeploymentSlack(BaseModel):
6
6
  model_config = ConfigDict(extra="forbid")
7
7
  channel: str
8
- message: str
8
+ message: str
@@ -1,5 +1,8 @@
1
1
  from __future__ import annotations
2
- from contentctl.objects.abstract_security_content_objects.detection_abstract import Detection_Abstract
2
+ from contentctl.objects.abstract_security_content_objects.detection_abstract import (
3
+ Detection_Abstract,
4
+ )
5
+
3
6
 
4
7
  class Detection(Detection_Abstract):
5
8
  # Customization to the Detection Class go here.
@@ -12,4 +15,4 @@ class Detection(Detection_Abstract):
12
15
  # them or modifying their behavior may cause
13
16
  # undefined issues with the contentctl tooling
14
17
  # or output of the tooling.
15
- pass
18
+ pass
@@ -8,6 +8,7 @@ class DetectionMetadata(BaseModel):
8
8
  """
9
9
  A model of the metadata line in a detection stanza in savedsearches.conf
10
10
  """
11
+
11
12
  # A bool indicating whether the detection is deprecated (serialized as an int, 1 or 0)
12
13
  deprecated: bool = Field(...)
13
14
 
@@ -11,6 +11,7 @@ class DetectionStanza(BaseModel):
11
11
  """
12
12
  A model representing a stanza for a detection in savedsearches.conf
13
13
  """
14
+
14
15
  # The lines that comprise this stanza, in the order they appear in the conf
15
16
  lines: list[str] = Field(...)
16
17
 
@@ -47,7 +48,9 @@ class DetectionStanza(BaseModel):
47
48
  raise Exception(f"No metadata for detection '{self.name}' found in stanza.")
48
49
 
49
50
  # Parse the metadata JSON into a model
50
- return DetectionMetadata.model_validate_json(meta_line[len(DetectionStanza.METADATA_LINE_PREFIX):])
51
+ return DetectionMetadata.model_validate_json(
52
+ meta_line[len(DetectionStanza.METADATA_LINE_PREFIX) :]
53
+ )
51
54
 
52
55
  @computed_field
53
56
  @cached_property
@@ -76,4 +79,6 @@ class DetectionStanza(BaseModel):
76
79
  :returns: True if the version still needs to be bumped
77
80
  :rtype: bool
78
81
  """
79
- return (self.hash != previous.hash) and (self.metadata.detection_version <= previous.metadata.detection_version)
82
+ return (self.hash != previous.hash) and (
83
+ self.metadata.detection_version <= previous.metadata.detection_version
84
+ )
@@ -1,42 +1,45 @@
1
1
  from __future__ import annotations
2
+
2
3
  import uuid
3
4
  from typing import TYPE_CHECKING, List, Optional, Union
5
+
4
6
  from pydantic import (
7
+ UUID4,
5
8
  BaseModel,
9
+ ConfigDict,
6
10
  Field,
7
- computed_field,
8
- UUID4,
9
11
  HttpUrl,
10
- ConfigDict,
11
- field_validator,
12
12
  ValidationInfo,
13
+ computed_field,
14
+ field_validator,
13
15
  model_serializer,
14
- model_validator
16
+ model_validator,
15
17
  )
18
+
16
19
  from contentctl.objects.story import Story
17
20
  from contentctl.objects.throttling import Throttling
21
+
18
22
  if TYPE_CHECKING:
19
23
  from contentctl.input.director import DirectorOutputDto
20
24
 
21
- from contentctl.objects.mitre_attack_enrichment import MitreAttackEnrichment
25
+ from contentctl.objects.annotated_types import CVE_TYPE, MITRE_ATTACK_ID_TYPE
26
+ from contentctl.objects.atomic import AtomicEnrichment, AtomicTest
22
27
  from contentctl.objects.constants import ATTACK_TACTICS_KILLCHAIN_MAPPING
23
- from contentctl.objects.observable import Observable
24
28
  from contentctl.objects.enums import (
25
- Cis18Value,
26
29
  AssetType,
27
- SecurityDomain,
30
+ Cis18Value,
28
31
  KillChainPhase,
29
32
  NistCategory,
30
- SecurityContentProductName
33
+ SecurityContentProductName,
34
+ SecurityDomain,
31
35
  )
32
- from contentctl.objects.atomic import AtomicEnrichment, AtomicTest
33
- from contentctl.objects.annotated_types import MITRE_ATTACK_ID_TYPE, CVE_TYPE
36
+ from contentctl.objects.mitre_attack_enrichment import MitreAttackEnrichment
34
37
 
35
38
 
36
39
  class DetectionTags(BaseModel):
37
40
  # detection spec
38
41
 
39
- model_config = ConfigDict(validate_default=False, extra='forbid')
42
+ model_config = ConfigDict(validate_default=False, extra="forbid")
40
43
  analytic_story: list[Story] = Field(...)
41
44
  asset_type: AssetType = Field(...)
42
45
  group: list[str] = []
@@ -44,9 +47,6 @@ class DetectionTags(BaseModel):
44
47
  mitre_attack_id: List[MITRE_ATTACK_ID_TYPE] = []
45
48
  nist: list[NistCategory] = []
46
49
 
47
- # TODO (cmcginley): observable should be removed as well, yes?
48
- # TODO (#249): Add pydantic validator to ensure observables are unique within a detection
49
- observable: List[Observable] = []
50
50
  product: list[SecurityContentProductName] = Field(..., min_length=1)
51
51
  throttling: Optional[Throttling] = None
52
52
  security_domain: SecurityDomain = Field(...)
@@ -54,7 +54,9 @@ class DetectionTags(BaseModel):
54
54
  atomic_guid: List[AtomicTest] = []
55
55
 
56
56
  # enrichment
57
- mitre_attack_enrichments: List[MitreAttackEnrichment] = Field([], validate_default=True)
57
+ mitre_attack_enrichments: List[MitreAttackEnrichment] = Field(
58
+ [], validate_default=True
59
+ )
58
60
 
59
61
  @computed_field
60
62
  @property
@@ -84,38 +86,6 @@ class DetectionTags(BaseModel):
84
86
  # TODO (#268): Validate manual_test has length > 0 if not None
85
87
  manual_test: Optional[str] = None
86
88
 
87
- # The following validator is temporarily disabled pending further discussions
88
- # @validator('message')
89
- # def validate_message(cls,v,values):
90
-
91
- # observables:list[Observable] = values.get("observable",[])
92
- # observable_names = set([o.name for o in observables])
93
- # #find all of the observables used in the message by name
94
- # name_match_regex = r"\$([^\s.]*)\$"
95
-
96
- # message_observables = set()
97
-
98
- # #Make sure that all observable names in
99
- # for match in re.findall(name_match_regex, v):
100
- # #Remove
101
- # match_without_dollars = match.replace("$", "")
102
- # message_observables.add(match_without_dollars)
103
-
104
- # missing_observables = message_observables - observable_names
105
- # unused_observables = observable_names - message_observables
106
- # if len(missing_observables) > 0:
107
- # raise ValueError(
108
- # "The following observables are referenced in the message, but were not declared as"
109
- # f" observables: {missing_observables}"
110
- # )
111
-
112
- # if len(unused_observables) > 0:
113
- # raise ValueError(
114
- # "The following observables were declared, but are not referenced in the message:"
115
- # f" {unused_observables}"
116
- # )
117
- # return v
118
-
119
89
  @model_serializer
120
90
  def serialize_model(self):
121
91
  # Since this field has no parent, there is no need to call super() serialization function
@@ -127,7 +97,7 @@ class DetectionTags(BaseModel):
127
97
  "nist": self.nist,
128
98
  "security_domain": self.security_domain,
129
99
  "mitre_attack_id": self.mitre_attack_id,
130
- "mitre_attack_enrichments": self.mitre_attack_enrichments
100
+ "mitre_attack_enrichments": self.mitre_attack_enrichments,
131
101
  }
132
102
 
133
103
  @model_validator(mode="after")
@@ -141,9 +111,13 @@ class DetectionTags(BaseModel):
141
111
  f" at runtime. Instead, this field contained: {self.mitre_attack_enrichments}"
142
112
  )
143
113
 
144
- output_dto: Union[DirectorOutputDto, None] = info.context.get("output_dto", None)
114
+ output_dto: Union[DirectorOutputDto, None] = info.context.get(
115
+ "output_dto", None
116
+ )
145
117
  if output_dto is None:
146
- raise ValueError("Context not provided to detection.detection_tags model post validator")
118
+ raise ValueError(
119
+ "Context not provided to detection.detection_tags model post validator"
120
+ )
147
121
 
148
122
  if output_dto.attack_enrichment.use_enrichment is False:
149
123
  return self
@@ -152,7 +126,9 @@ class DetectionTags(BaseModel):
152
126
  missing_tactics: list[str] = []
153
127
  for mitre_attack_id in self.mitre_attack_id:
154
128
  try:
155
- mitre_enrichments.append(output_dto.attack_enrichment.getEnrichmentByMitreID(mitre_attack_id))
129
+ mitre_enrichments.append(
130
+ output_dto.attack_enrichment.getEnrichmentByMitreID(mitre_attack_id)
131
+ )
156
132
  except Exception:
157
133
  missing_tactics.append(mitre_attack_id)
158
134
 
@@ -163,7 +139,7 @@ class DetectionTags(BaseModel):
163
139
 
164
140
  return self
165
141
 
166
- '''
142
+ """
167
143
  @field_validator('mitre_attack_enrichments', mode="before")
168
144
  @classmethod
169
145
  def addAttackEnrichments(cls, v:list[MitreAttackEnrichment], info:ValidationInfo)->list[MitreAttackEnrichment]:
@@ -181,31 +157,43 @@ class DetectionTags(BaseModel):
181
157
  enrichments = []
182
158
 
183
159
  return enrichments
184
- '''
160
+ """
185
161
 
186
- @field_validator('analytic_story', mode="before")
162
+ @field_validator("analytic_story", mode="before")
187
163
  @classmethod
188
- def mapStoryNamesToStoryObjects(cls, v: list[str], info: ValidationInfo) -> list[Story]:
164
+ def mapStoryNamesToStoryObjects(
165
+ cls, v: list[str], info: ValidationInfo
166
+ ) -> list[Story]:
189
167
  if info.context is None:
190
168
  raise ValueError("ValidationInfo.context unexpectedly null")
191
169
 
192
- return Story.mapNamesToSecurityContentObjects(v, info.context.get("output_dto", None))
170
+ return Story.mapNamesToSecurityContentObjects(
171
+ v, info.context.get("output_dto", None)
172
+ )
193
173
 
194
174
  def getAtomicGuidStringArray(self) -> List[str]:
195
- return [str(atomic_guid.auto_generated_guid) for atomic_guid in self.atomic_guid]
175
+ return [
176
+ str(atomic_guid.auto_generated_guid) for atomic_guid in self.atomic_guid
177
+ ]
196
178
 
197
- @field_validator('atomic_guid', mode="before")
179
+ @field_validator("atomic_guid", mode="before")
198
180
  @classmethod
199
- def mapAtomicGuidsToAtomicTests(cls, v: List[UUID4], info: ValidationInfo) -> List[AtomicTest]:
181
+ def mapAtomicGuidsToAtomicTests(
182
+ cls, v: List[UUID4], info: ValidationInfo
183
+ ) -> List[AtomicTest]:
200
184
  if len(v) == 0:
201
185
  return []
202
186
 
203
187
  if info.context is None:
204
188
  raise ValueError("ValidationInfo.context unexpectedly null")
205
189
 
206
- output_dto: Union[DirectorOutputDto, None] = info.context.get("output_dto", None)
190
+ output_dto: Union[DirectorOutputDto, None] = info.context.get(
191
+ "output_dto", None
192
+ )
207
193
  if output_dto is None:
208
- raise ValueError("Context not provided to detection.detection_tags.atomic_guid validator")
194
+ raise ValueError(
195
+ "Context not provided to detection.detection_tags.atomic_guid validator"
196
+ )
209
197
 
210
198
  atomic_enrichment: AtomicEnrichment = output_dto.atomic_enrichment
211
199
 
@@ -247,4 +235,6 @@ class DetectionTags(BaseModel):
247
235
  elif len(missing_tests) > 0:
248
236
  raise ValueError(missing_tests_string)
249
237
 
250
- return matched_tests + [AtomicTest.AtomicTestWhenTestIsMissing(test) for test in missing_tests]
238
+ return matched_tests + [
239
+ AtomicTest.AtomicTestWhenTestIsMissing(test) for test in missing_tests
240
+ ]
@@ -1,71 +1,102 @@
1
1
  from __future__ import annotations
2
- from pydantic import BaseModel, Field, model_serializer
2
+
3
3
  from typing import TYPE_CHECKING
4
+
5
+ from pydantic import BaseModel, Field, model_serializer
6
+
4
7
  if TYPE_CHECKING:
5
8
  from contentctl.objects.detection import Detection
9
+
6
10
  from contentctl.objects.enums import AnalyticsType
11
+
7
12
  DRILLDOWN_SEARCH_PLACEHOLDER = "%original_detection_search%"
8
13
  EARLIEST_OFFSET = "$info_min_time$"
9
14
  LATEST_OFFSET = "$info_max_time$"
10
15
  RISK_SEARCH = "index = risk starthoursago = 168 endhoursago = 0 | stats count values(search_name) values(risk_message) values(analyticstories) values(annotations._all) values(annotations.mitre_attack.mitre_tactic) "
11
16
 
17
+
12
18
  class Drilldown(BaseModel):
13
19
  name: str = Field(..., description="The name of the drilldown search", min_length=5)
14
- search: str = Field(..., description="The text of a drilldown search. This must be valid SPL.", min_length=1)
15
- earliest_offset:None | str = Field(...,
16
- description="Earliest offset time for the drilldown search. "
17
- f"The most common value for this field is '{EARLIEST_OFFSET}', "
18
- "but it is NOT the default value and must be supplied explicitly.",
19
- min_length= 1)
20
- latest_offset:None | str = Field(...,
21
- description="Latest offset time for the driolldown search. "
22
- f"The most common value for this field is '{LATEST_OFFSET}', "
23
- "but it is NOT the default value and must be supplied explicitly.",
24
- min_length= 1)
20
+ search: str = Field(
21
+ ...,
22
+ description="The text of a drilldown search. This must be valid SPL.",
23
+ min_length=1,
24
+ )
25
+ earliest_offset: None | str = Field(
26
+ ...,
27
+ description="Earliest offset time for the drilldown search. "
28
+ f"The most common value for this field is '{EARLIEST_OFFSET}', "
29
+ "but it is NOT the default value and must be supplied explicitly.",
30
+ min_length=1,
31
+ )
32
+ latest_offset: None | str = Field(
33
+ ...,
34
+ description="Latest offset time for the driolldown search. "
35
+ f"The most common value for this field is '{LATEST_OFFSET}', "
36
+ "but it is NOT the default value and must be supplied explicitly.",
37
+ min_length=1,
38
+ )
25
39
 
26
- # TODO (cmcginley): @ljstella the drilldowns will need to be updated
27
40
  @classmethod
28
41
  def constructDrilldownsFromDetection(cls, detection: Detection) -> list[Drilldown]:
29
- victim_observables = [o for o in detection.tags.observable if o.role[0] == "Victim"]
42
+ # Ensure the rba object is defined
43
+ if detection.rba is None:
44
+ raise NotImplementedError(
45
+ f"Unexpected error: Detection '{detection.name}' has no RBA objects associated "
46
+ "with it; cannot construct drilldowns."
47
+ )
48
+
49
+ victim_observables = [o for o in detection.rba.risk_objects]
30
50
  if len(victim_observables) == 0 or detection.type == AnalyticsType.Hunting:
31
51
  # No victims, so no drilldowns
32
52
  return []
33
53
  print(f"Adding default drilldowns for [{detection.name}]")
34
- variableNamesString = ' and '.join([f"${o.name}$" for o in victim_observables])
54
+ variableNamesString = " and ".join([f"${o.field}$" for o in victim_observables])
35
55
  nameField = f"View the detection results for {variableNamesString}"
36
- appendedSearch = " | search " + ' '.join([f"{o.name} = ${o.name}$" for o in victim_observables])
56
+ appendedSearch = " | search " + " ".join(
57
+ [f"{o.field} = ${o.field}$" for o in victim_observables]
58
+ )
37
59
  search_field = f"{detection.search}{appendedSearch}"
38
- detection_results = cls(name=nameField, earliest_offset=EARLIEST_OFFSET, latest_offset=LATEST_OFFSET, search=search_field)
39
-
40
-
60
+ detection_results = cls(
61
+ name=nameField,
62
+ earliest_offset=EARLIEST_OFFSET,
63
+ latest_offset=LATEST_OFFSET,
64
+ search=search_field,
65
+ )
66
+
41
67
  nameField = f"View risk events for the last 7 days for {variableNamesString}"
42
- fieldNamesListString = ', '.join([o.name for o in victim_observables])
68
+ fieldNamesListString = ", ".join([o.field for o in victim_observables])
43
69
  search_field = f"{RISK_SEARCH}by {fieldNamesListString} {appendedSearch}"
44
- risk_events_last_7_days = cls(name=nameField, earliest_offset=None, latest_offset=None, search=search_field)
70
+ risk_events_last_7_days = cls(
71
+ name=nameField,
72
+ earliest_offset=None,
73
+ latest_offset=None,
74
+ search=search_field,
75
+ )
45
76
 
46
- return [detection_results,risk_events_last_7_days]
47
-
77
+ return [detection_results, risk_events_last_7_days]
48
78
 
49
- def perform_search_substitutions(self, detection:Detection)->None:
79
+ def perform_search_substitutions(self, detection: Detection) -> None:
50
80
  """Replaces the field DRILLDOWN_SEARCH_PLACEHOLDER (%original_detection_search%)
51
81
  with the search contained in the detection. We do this so that the YML does not
52
82
  need the search copy/pasted from the search field into the drilldown object.
53
83
 
54
84
  Args:
55
85
  detection (Detection): Detection to be used to update the search field of the drilldown
56
- """
57
- self.search = self.search.replace(DRILLDOWN_SEARCH_PLACEHOLDER, detection.search)
58
-
86
+ """
87
+ self.search = self.search.replace(
88
+ DRILLDOWN_SEARCH_PLACEHOLDER, detection.search
89
+ )
59
90
 
60
91
  @model_serializer
61
- def serialize_model(self) -> dict[str,str]:
62
- #Call serializer for parent
63
- model:dict[str,str] = {}
92
+ def serialize_model(self) -> dict[str, str]:
93
+ # Call serializer for parent
94
+ model: dict[str, str] = {}
64
95
 
65
- model['name'] = self.name
66
- model['search'] = self.search
96
+ model["name"] = self.name
97
+ model["search"] = self.search
67
98
  if self.earliest_offset is not None:
68
- model['earliest_offset'] = self.earliest_offset
99
+ model["earliest_offset"] = self.earliest_offset
69
100
  if self.latest_offset is not None:
70
- model['latest_offset'] = self.latest_offset
71
- return model
101
+ model["latest_offset"] = self.latest_offset
102
+ return model