contentctl 5.0.0a2__py3-none-any.whl → 5.0.1__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 (114) 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 +83 -53
  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 +255 -323
  34. contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +111 -46
  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 +47 -35
  41. contentctl/objects/baseline_tags.py +29 -22
  42. contentctl/objects/config.py +1 -1
  43. contentctl/objects/constants.py +32 -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 +53 -31
  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 +68 -11
  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 +54 -49
  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/analyticstories_detections.j2 +1 -1
  98. contentctl/output/templates/analyticstories_stories.j2 +1 -1
  99. contentctl/output/templates/es_investigations_investigations.j2 +1 -1
  100. contentctl/output/templates/es_investigations_stories.j2 +1 -1
  101. contentctl/output/templates/savedsearches_baselines.j2 +2 -2
  102. contentctl/output/templates/savedsearches_detections.j2 +2 -8
  103. contentctl/output/templates/savedsearches_investigations.j2 +2 -2
  104. contentctl/output/templates/transforms.j2 +2 -4
  105. contentctl/output/yml_writer.py +18 -24
  106. contentctl/templates/stories/cobalt_strike.yml +1 -0
  107. {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/METADATA +1 -1
  108. contentctl-5.0.1.dist-info/RECORD +168 -0
  109. contentctl/actions/initialize_old.py +0 -245
  110. contentctl/objects/observable.py +0 -39
  111. contentctl-5.0.0a2.dist-info/RECORD +0 -170
  112. {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/LICENSE.md +0 -0
  113. {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/WHEEL +0 -0
  114. {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/entry_points.txt +0 -0
@@ -1,10 +1,11 @@
1
1
  from __future__ import annotations
2
2
  from pydantic import BaseModel, Field, ConfigDict, HttpUrl, field_validator
3
- from typing import List, Annotated
3
+ from typing import List
4
4
  from enum import StrEnum
5
5
  import datetime
6
6
  from contentctl.objects.annotated_types import MITRE_ATTACK_ID_TYPE
7
7
 
8
+
8
9
  class MitreTactics(StrEnum):
9
10
  RECONNAISSANCE = "Reconnaissance"
10
11
  RESOURCE_DEVELOPMENT = "Resource Development"
@@ -31,16 +32,17 @@ class AttackGroupMatrix(StrEnum):
31
32
  class AttackGroupType(StrEnum):
32
33
  intrusion_set = "intrusion-set"
33
34
 
35
+
34
36
  class MitreExternalReference(BaseModel):
35
- model_config = ConfigDict(extra='forbid')
37
+ model_config = ConfigDict(extra="forbid")
36
38
  source_name: str
37
- external_id: None | str = None
39
+ external_id: None | str = None
38
40
  url: None | HttpUrl = None
39
41
  description: None | str = None
40
42
 
41
43
 
42
44
  class MitreAttackGroup(BaseModel):
43
- model_config = ConfigDict(extra='forbid')
45
+ model_config = ConfigDict(extra="forbid")
44
46
  contributors: list[str] = []
45
47
  created: datetime.datetime
46
48
  created_by_ref: str
@@ -53,45 +55,44 @@ class MitreAttackGroup(BaseModel):
53
55
  matrix: list[AttackGroupMatrix]
54
56
  mitre_attack_spec_version: None | str
55
57
  mitre_version: str
56
- #assume that if the deprecated field is not present, then the group is not deprecated
58
+ # assume that if the deprecated field is not present, then the group is not deprecated
57
59
  mitre_deprecated: bool
58
60
  modified: datetime.datetime
59
61
  modified_by_ref: str
60
62
  object_marking_refs: list[str]
61
63
  type: AttackGroupType
62
64
  url: HttpUrl
63
-
64
65
 
65
66
  @field_validator("mitre_deprecated", mode="before")
66
- def standardize_mitre_deprecated(cls, mitre_deprecated:bool | None) -> bool:
67
- '''
67
+ def standardize_mitre_deprecated(cls, mitre_deprecated: bool | None) -> bool:
68
+ """
68
69
  For some reason, the API will return either a bool for mitre_deprecated OR
69
70
  None. We simplify our typing by converting None to False, and assuming that
70
71
  if deprecated is None, then the group is not deprecated.
71
- '''
72
+ """
72
73
  if mitre_deprecated is None:
73
74
  return False
74
75
  return mitre_deprecated
75
76
 
76
77
  @field_validator("contributors", mode="before")
77
- def standardize_contributors(cls, contributors:list[str] | None) -> list[str]:
78
- '''
78
+ def standardize_contributors(cls, contributors: list[str] | None) -> list[str]:
79
+ """
79
80
  For some reason, the API will return either a list of strings for contributors OR
80
81
  None. We simplify our typing by converting None to an empty list.
81
- '''
82
+ """
82
83
  if contributors is None:
83
84
  return []
84
85
  return contributors
85
86
 
86
- class MitreAttackEnrichment(BaseModel):
87
87
 
88
- ConfigDict(extra='forbid')
88
+ class MitreAttackEnrichment(BaseModel):
89
+ ConfigDict(extra="forbid")
89
90
  mitre_attack_id: MITRE_ATTACK_ID_TYPE = Field(...)
90
91
  mitre_attack_technique: str = Field(...)
91
92
  mitre_attack_tactics: List[MitreTactics] = Field(...)
92
93
  mitre_attack_groups: List[str] = Field(...)
93
- #Exclude this field from serialization - it is very large and not useful in JSON objects
94
+ # Exclude this field from serialization - it is very large and not useful in JSON objects
94
95
  mitre_attack_group_objects: list[MitreAttackGroup] = Field(..., exclude=True)
96
+
95
97
  def __hash__(self) -> int:
96
98
  return id(self)
97
-
@@ -14,6 +14,7 @@ class NotableAction(BaseModel):
14
14
  :param security_domain: the domain associated with the notable action and related rule (detection/search)
15
15
  :param severity: severity (e.g. "high") associated with the notable action and related rule (detection/search)
16
16
  """
17
+
17
18
  rule_name: str
18
19
  rule_description: str
19
20
  security_domain: str
@@ -32,5 +33,5 @@ class NotableAction(BaseModel):
32
33
  rule_name=dict_["action.notable.param.rule_title"],
33
34
  rule_description=dict_["action.notable.param.rule_description"],
34
35
  security_domain=dict_["action.notable.param.security_domain"],
35
- severity=dict_["action.notable.param.severity"]
36
+ severity=dict_["action.notable.param.severity"],
36
37
  )
@@ -13,9 +13,7 @@ class NotableEvent(BaseModel):
13
13
 
14
14
  # Allowing fields that aren't explicitly defined to be passed since some of the risk event's
15
15
  # fields vary depending on the SPL which generated them
16
- model_config = ConfigDict(
17
- extra='allow'
18
- )
16
+ model_config = ConfigDict(extra="allow")
19
17
 
20
18
  def validate_against_detection(self, detection: Detection) -> None:
21
19
  raise NotImplementedError()
@@ -1,5 +1,5 @@
1
1
  from __future__ import annotations
2
- from typing import TYPE_CHECKING,Self
2
+ from typing import Self
3
3
  from pydantic import model_validator, Field, FilePath
4
4
 
5
5
 
@@ -10,57 +10,59 @@ from contentctl.objects.enums import PlaybookType
10
10
 
11
11
  class Playbook(SecurityContentObject):
12
12
  type: PlaybookType = Field(...)
13
-
13
+
14
14
  # Override the type definition for filePath.
15
15
  # This MUST be backed by a file and cannot be None
16
16
  file_path: FilePath
17
-
17
+
18
18
  how_to_implement: str = Field(min_length=4)
19
19
  playbook: str = Field(min_length=4)
20
- app_list: list[str] = Field(...,min_length=0)
20
+ app_list: list[str] = Field(..., min_length=0)
21
21
  tags: PlaybookTag = Field(...)
22
-
23
22
 
24
-
25
23
  @model_validator(mode="after")
26
- def ensureJsonAndPyFilesExist(self)->Self:
24
+ def ensureJsonAndPyFilesExist(self) -> Self:
27
25
  json_file_path = self.file_path.with_suffix(".json")
28
26
  python_file_path = self.file_path.with_suffix(".py")
29
- missing:list[str] = []
27
+ missing: list[str] = []
30
28
  if not json_file_path.is_file():
31
- missing.append(f"Playbook file named '{self.file_path.name}' MUST "\
32
- f"have a .json file named '{json_file_path.name}', "\
33
- "but it does not exist")
34
-
29
+ missing.append(
30
+ f"Playbook file named '{self.file_path.name}' MUST "
31
+ f"have a .json file named '{json_file_path.name}', "
32
+ "but it does not exist"
33
+ )
34
+
35
35
  if not python_file_path.is_file():
36
- missing.append(f"Playbook file named '{self.file_path.name}' MUST "\
37
- f"have a .py file named '{python_file_path.name}', "\
38
- "but it does not exist")
39
-
40
-
36
+ missing.append(
37
+ f"Playbook file named '{self.file_path.name}' MUST "
38
+ f"have a .py file named '{python_file_path.name}', "
39
+ "but it does not exist"
40
+ )
41
+
41
42
  if len(missing) == 0:
42
43
  return self
43
44
  else:
44
- missing_files_string = '\n - '.join(missing)
45
+ missing_files_string = "\n - ".join(missing)
45
46
  raise ValueError(f"Playbook files missing:\n -{missing_files_string}")
46
47
 
47
-
48
- #Override playbook file name checking FOR NOW
48
+ # Override playbook file name checking FOR NOW
49
49
  @model_validator(mode="after")
50
- def ensureFileNameMatchesSearchName(self)->Self:
51
- file_name = self.name \
52
- .replace(' ', '_') \
53
- .replace('-','_') \
54
- .replace('.','_') \
55
- .replace('/','_') \
56
- .lower() + ".yml"
57
-
58
- #allow different capitalization FOR NOW in playbook file names
59
- if (self.file_path is not None and file_name != self.file_path.name.lower()):
60
- raise ValueError(f"The file name MUST be based off the content 'name' field:\n"\
61
- f"\t- Expected File Name: {file_name}\n"\
62
- f"\t- Actual File Name : {self.file_path.name}")
50
+ def ensureFileNameMatchesSearchName(self) -> Self:
51
+ file_name = (
52
+ self.name.replace(" ", "_")
53
+ .replace("-", "_")
54
+ .replace(".", "_")
55
+ .replace("/", "_")
56
+ .lower()
57
+ + ".yml"
58
+ )
63
59
 
64
- return self
60
+ # allow different capitalization FOR NOW in playbook file names
61
+ if self.file_path is not None and file_name != self.file_path.name.lower():
62
+ raise ValueError(
63
+ f"The file name MUST be based off the content 'name' field:\n"
64
+ f"\t- Expected File Name: {file_name}\n"
65
+ f"\t- Actual File Name : {self.file_path.name}"
66
+ )
65
67
 
66
-
68
+ return self
@@ -1,26 +1,31 @@
1
1
  from __future__ import annotations
2
- from typing import TYPE_CHECKING, Optional, List
3
- from pydantic import BaseModel, Field,ConfigDict
2
+ from typing import Optional, List
3
+ from pydantic import BaseModel, Field, ConfigDict
4
4
  import enum
5
5
  from contentctl.objects.detection import Detection
6
6
 
7
7
 
8
- class PlaybookProduct(str,enum.Enum):
8
+ class PlaybookProduct(str, enum.Enum):
9
9
  SPLUNK_SOAR = "Splunk SOAR"
10
10
 
11
- class PlaybookUseCase(str,enum.Enum):
11
+
12
+ class PlaybookUseCase(str, enum.Enum):
12
13
  PHISHING = "Phishing"
13
14
  ENDPOINT = "Endpoint"
14
15
  ENRICHMENT = "Enrichment"
15
-
16
- class PlaybookType(str,enum.Enum):
16
+
17
+
18
+ class PlaybookType(str, enum.Enum):
17
19
  INPUT = "Input"
18
20
  AUTOMATION = "Automation"
19
21
 
20
- class VpeType(str,enum.Enum):
22
+
23
+ class VpeType(str, enum.Enum):
21
24
  MODERN = "Modern"
22
25
  CLASSIC = "Classic"
23
- class DefendTechnique(str,enum.Enum):
26
+
27
+
28
+ class DefendTechnique(str, enum.Enum):
24
29
  D3_AL = "D3-AL"
25
30
  D3_DNSDL = "D3-DNSDL"
26
31
  D3_DA = "D3-DA"
@@ -35,20 +40,21 @@ class DefendTechnique(str,enum.Enum):
35
40
  D3_FHRA = "D3-FHRA"
36
41
  D3_SRA = "D3-SRA"
37
42
  D3_RUAA = "D3-RUAA"
43
+
44
+
38
45
  class PlaybookTag(BaseModel):
39
46
  model_config = ConfigDict(extra="forbid")
40
47
  analytic_story: Optional[list] = None
41
48
  detections: Optional[list] = None
42
- platform_tags: list[str] = Field(...,min_length=0)
49
+ platform_tags: list[str] = Field(..., min_length=0)
43
50
  playbook_type: PlaybookType = Field(...)
44
51
  vpe_type: VpeType = Field(...)
45
52
  playbook_fields: list[str] = Field([], min_length=0)
46
- product: list[PlaybookProduct] = Field([],min_length=0)
47
- use_cases: list[PlaybookUseCase] = Field([],min_length=0)
53
+ product: list[PlaybookProduct] = Field([], min_length=0)
54
+ use_cases: list[PlaybookUseCase] = Field([], min_length=0)
48
55
  defend_technique_id: Optional[List[DefendTechnique]] = None
49
-
50
- labels:list[str] = []
51
- playbook_outputs:list[str] = []
52
-
56
+
57
+ labels: list[str] = []
58
+ playbook_outputs: list[str] = []
59
+
53
60
  detection_objects: list[Detection] = []
54
-
contentctl/objects/rba.py CHANGED
@@ -1,17 +1,22 @@
1
- from enum import Enum
2
- from pydantic import BaseModel, computed_field, Field
1
+ from __future__ import annotations
2
+
3
3
  from abc import ABC
4
- from typing import Set, Annotated
5
- from contentctl.objects.enums import RiskSeverity
4
+ from enum import Enum
5
+ from typing import Annotated, Set
6
6
 
7
+ from pydantic import BaseModel, Field, computed_field, model_serializer
8
+
9
+ from contentctl.objects.enums import RiskSeverity
7
10
 
8
11
  RiskScoreValue_Type = Annotated[int, Field(ge=1, le=100)]
9
12
 
13
+
10
14
  class RiskObjectType(str, Enum):
11
15
  SYSTEM = "system"
12
16
  USER = "user"
13
17
  OTHER = "other"
14
18
 
19
+
15
20
  class ThreatObjectType(str, Enum):
16
21
  CERTIFICATE_COMMON_NAME = "certificate_common_name"
17
22
  CERTIFICATE_ORGANIZATION = "certificate_organization"
@@ -40,6 +45,7 @@ class ThreatObjectType(str, Enum):
40
45
  TLS_HASH = "tls_hash"
41
46
  URL = "url"
42
47
 
48
+
43
49
  class RiskObject(BaseModel):
44
50
  field: str
45
51
  type: RiskObjectType
@@ -48,6 +54,29 @@ class RiskObject(BaseModel):
48
54
  def __hash__(self):
49
55
  return hash((self.field, self.type, self.score))
50
56
 
57
+ def __lt__(self, other: RiskObject) -> bool:
58
+ if (
59
+ f"{self.field}{self.type}{self.score}"
60
+ < f"{other.field}{other.type}{other.score}"
61
+ ):
62
+ return True
63
+ return False
64
+
65
+ @model_serializer
66
+ def serialize_risk_object(self) -> dict[str, str | int]:
67
+ """
68
+ We define this explicitly for two reasons, even though the automatic
69
+ serialization works correctly. First we want to enforce a specific
70
+ field order for reasons of readability. Second, some of the fields
71
+ actually have different names than they do in the object.
72
+ """
73
+ return {
74
+ "risk_object_field": self.field,
75
+ "risk_object_type": self.type,
76
+ "risk_score": self.score,
77
+ }
78
+
79
+
51
80
  class ThreatObject(BaseModel):
52
81
  field: str
53
82
  type: ThreatObjectType
@@ -55,26 +84,45 @@ class ThreatObject(BaseModel):
55
84
  def __hash__(self):
56
85
  return hash((self.field, self.type))
57
86
 
87
+ def __lt__(self, other: ThreatObject) -> bool:
88
+ if f"{self.field}{self.type}" < f"{other.field}{other.type}":
89
+ return True
90
+ return False
91
+
92
+ @model_serializer
93
+ def serialize_threat_object(self) -> dict[str, str]:
94
+ """
95
+ We define this explicitly for two reasons, even though the automatic
96
+ serialization works correctly. First we want to enforce a specific
97
+ field order for reasons of readability. Second, some of the fields
98
+ actually have different names than they do in the object.
99
+ """
100
+ return {
101
+ "threat_object_field": self.field,
102
+ "threat_object_type": self.type,
103
+ }
104
+
105
+
58
106
  class RBAObject(BaseModel, ABC):
59
107
  message: str
60
108
  risk_objects: Annotated[Set[RiskObject], Field(min_length=1)]
61
109
  threat_objects: Set[ThreatObject]
62
110
 
63
-
64
-
65
111
  @computed_field
66
112
  @property
67
- def risk_score(self)->RiskScoreValue_Type:
113
+ def risk_score(self) -> RiskScoreValue_Type:
68
114
  # First get the maximum score associated with
69
115
  # a risk object. If there are no objects, then
70
116
  # we should throw an exception.
71
117
  if len(self.risk_objects) == 0:
72
- raise Exception("There must be at least one Risk Object present to get Severity.")
118
+ raise Exception(
119
+ "There must be at least one Risk Object present to get Severity."
120
+ )
73
121
  return max([risk_object.score for risk_object in self.risk_objects])
74
-
122
+
75
123
  @computed_field
76
124
  @property
77
- def severity(self)->RiskSeverity:
125
+ def severity(self) -> RiskSeverity:
78
126
  if 0 <= self.risk_score <= 20:
79
127
  return RiskSeverity.INFORMATIONAL
80
128
  elif 20 < self.risk_score <= 40:
@@ -86,5 +134,14 @@ class RBAObject(BaseModel, ABC):
86
134
  elif 80 < self.risk_score <= 100:
87
135
  return RiskSeverity.CRITICAL
88
136
  else:
89
- raise Exception(f"Error getting severity - risk_score must be between 0-100, but was actually {self.risk_score}")
137
+ raise Exception(
138
+ f"Error getting severity - risk_score must be between 0-100, but was actually {self.risk_score}"
139
+ )
90
140
 
141
+ @model_serializer
142
+ def serialize_rba(self) -> dict[str, str | list[dict[str, str | int]]]:
143
+ return {
144
+ "message": self.message,
145
+ "risk_objects": [obj.model_dump() for obj in sorted(self.risk_objects)],
146
+ "threat_objects": [obj.model_dump() for obj in sorted(self.threat_objects)],
147
+ }
@@ -18,6 +18,7 @@ class RiskAnalysisAction(BaseModel):
18
18
  :param message: the message associated w/ the risk event (NOTE: may contain macros of the form
19
19
  $...$ which should be replaced with real values in the resulting risk events)
20
20
  """
21
+
21
22
  risk_objects: list[RiskObject]
22
23
  message: str
23
24
 
@@ -80,21 +81,24 @@ class RiskAnalysisAction(BaseModel):
80
81
  # TODO (#231): add validation ensuring at least 1 risk objects
81
82
  for entry in object_dicts:
82
83
  if "risk_object_field" in entry:
83
- risk_objects.append(RiskObject(
84
- field=entry["risk_object_field"],
85
- type=entry["risk_object_type"],
86
- score=int(entry["risk_score"])
87
- ))
84
+ risk_objects.append(
85
+ RiskObject(
86
+ field=entry["risk_object_field"],
87
+ type=entry["risk_object_type"],
88
+ score=int(entry["risk_score"]),
89
+ )
90
+ )
88
91
  elif "threat_object_field" in entry:
89
- threat_objects.append(ThreatObject(
90
- field=entry["threat_object_field"],
91
- type=entry["threat_object_type"]
92
- ))
92
+ threat_objects.append(
93
+ ThreatObject(
94
+ field=entry["threat_object_field"],
95
+ type=entry["threat_object_type"],
96
+ )
97
+ )
93
98
  else:
94
99
  raise ValueError(
95
100
  f"Unexpected object within 'action.risk.param._risk': {entry}"
96
101
  )
97
102
  return cls(
98
- risk_objects=risk_objects,
99
- message=dict_["action.risk.param._risk_message"]
103
+ risk_objects=risk_objects, message=dict_["action.risk.param._risk_message"]
100
104
  )
@@ -1,9 +1,17 @@
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
14
+ from contentctl.objects.errors import ValidationFailed
7
15
  from contentctl.objects.rba import RiskObject
8
16
 
9
17
 
@@ -15,11 +23,11 @@ class RiskEvent(BaseModel):
15
23
 
16
24
  # The subject of the risk event (e.g. a username, process name, system name, account ID, etc.)
17
25
  # (not to be confused w/ the risk object from the detection)
18
- es_risk_object: int | str
26
+ es_risk_object: int | str = Field(alias="risk_object")
19
27
 
20
28
  # The type of the risk object from ES (e.g. user, system, or other) (not to be confused w/
21
29
  # the risk object from the detection)
22
- es_risk_object_type: str
30
+ es_risk_object_type: str = Field(alias="risk_object_type")
23
31
 
24
32
  # The level of risk associated w/ the risk event
25
33
  risk_score: int
@@ -35,8 +43,7 @@ class RiskEvent(BaseModel):
35
43
 
36
44
  # The MITRE ATT&CK IDs
37
45
  annotations_mitre_attack: list[str] = Field(
38
- alias="annotations.mitre_attack",
39
- default=[]
46
+ alias="annotations.mitre_attack", default=[]
40
47
  )
41
48
 
42
49
  # Contributing events search query (we use this to derive the corresponding field from the
@@ -48,9 +55,7 @@ class RiskEvent(BaseModel):
48
55
 
49
56
  # Allowing fields that aren't explicitly defined to be passed since some of the risk event's
50
57
  # fields vary depending on the SPL which generated them
51
- model_config = ConfigDict(
52
- extra="allow"
53
- )
58
+ model_config = ConfigDict(extra="allow")
54
59
 
55
60
  @field_validator("annotations_mitre_attack", "analyticstories", mode="before")
56
61
  @classmethod
@@ -72,7 +77,9 @@ class RiskEvent(BaseModel):
72
77
  event(s). Useful for mapping back to a risk object in the detection.
73
78
  """
74
79
  pattern = re.compile(
75
- r"\| savedsearch \"" + self.search_name + r"\" \| search (?P<field>[^=]+)=.+"
80
+ r"\| savedsearch \""
81
+ + self.search_name
82
+ + r"\" \| search (?P<field>[^=]+)=.+"
76
83
  )
77
84
  match = pattern.search(self.contributing_events_search)
78
85
  if match is None:
@@ -121,7 +128,9 @@ class RiskEvent(BaseModel):
121
128
  :param detection: the detection associated w/ this risk event
122
129
  :raises: ValidationFailed
123
130
  """
124
- 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
+ ):
125
134
  raise ValidationFailed(
126
135
  f"MITRE ATT&CK IDs in risk event ({self.annotations_mitre_attack}) do not match those"
127
136
  f" in detection ({detection.tags.mitre_attack_id})."
@@ -134,7 +143,9 @@ class RiskEvent(BaseModel):
134
143
  :raises: ValidationFailed
135
144
  """
136
145
  # Render the detection analytic_story to a list of strings before comparing
137
- 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
+ ]
138
149
  if sorted(self.analyticstories) != sorted(detection_analytic_story):
139
150
  raise ValidationFailed(
140
151
  f"Analytic stories in risk event ({self.analyticstories}) do not match those"
@@ -174,16 +185,12 @@ class RiskEvent(BaseModel):
174
185
  # placeholder
175
186
  tmp_placeholder = "PLACEHOLDERPATTERNFORESCAPING"
176
187
  escaped_source_message_with_placeholder: str = re.escape(
177
- field_replacement_pattern.sub(
178
- tmp_placeholder,
179
- detection.rba.message
180
- )
188
+ field_replacement_pattern.sub(tmp_placeholder, detection.rba.message)
181
189
  )
182
190
  placeholder_replacement_pattern = re.compile(tmp_placeholder)
183
191
  final_risk_message_pattern = re.compile(
184
192
  placeholder_replacement_pattern.sub(
185
- r"[\\s\\S]*\\S[\\s\\S]*",
186
- escaped_source_message_with_placeholder
193
+ r"[\\s\\S]*\\S[\\s\\S]*", escaped_source_message_with_placeholder
187
194
  )
188
195
  )
189
196
 
@@ -191,8 +198,8 @@ class RiskEvent(BaseModel):
191
198
  if final_risk_message_pattern.match(self.risk_message) is None:
192
199
  raise ValidationFailed(
193
200
  "Risk message in event does not match the pattern set by the detection. Message in "
194
- f"risk event: \"{self.risk_message}\". Message in detection: "
195
- f"\"{detection.rba.message}\"."
201
+ f'risk event: "{self.risk_message}". Message in detection: '
202
+ f'"{detection.rba.message}".'
196
203
  )
197
204
 
198
205
  def validate_risk_against_risk_objects(self, risk_objects: set[RiskObject]) -> None:
@@ -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