contentctl 3.6.0__py3-none-any.whl → 4.0.2__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 (142) hide show
  1. contentctl/actions/build.py +89 -0
  2. contentctl/actions/detection_testing/DetectionTestingManager.py +48 -49
  3. contentctl/actions/detection_testing/GitService.py +148 -230
  4. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +14 -24
  5. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +43 -17
  6. contentctl/actions/detection_testing/views/DetectionTestingView.py +3 -2
  7. contentctl/actions/detection_testing/views/DetectionTestingViewFile.py +0 -8
  8. contentctl/actions/doc_gen.py +1 -1
  9. contentctl/actions/initialize.py +28 -65
  10. contentctl/actions/inspect.py +260 -0
  11. contentctl/actions/new_content.py +106 -13
  12. contentctl/actions/release_notes.py +168 -144
  13. contentctl/actions/reporting.py +24 -13
  14. contentctl/actions/test.py +39 -20
  15. contentctl/actions/validate.py +25 -48
  16. contentctl/contentctl.py +196 -754
  17. contentctl/enrichments/attack_enrichment.py +69 -19
  18. contentctl/enrichments/cve_enrichment.py +28 -13
  19. contentctl/helper/link_validator.py +24 -26
  20. contentctl/helper/utils.py +7 -3
  21. contentctl/input/director.py +139 -201
  22. contentctl/input/new_content_questions.py +63 -61
  23. contentctl/input/sigma_converter.py +1 -2
  24. contentctl/input/ssa_detection_builder.py +16 -7
  25. contentctl/input/yml_reader.py +4 -3
  26. contentctl/objects/abstract_security_content_objects/detection_abstract.py +487 -154
  27. contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +155 -51
  28. contentctl/objects/alert_action.py +40 -0
  29. contentctl/objects/atomic.py +212 -0
  30. contentctl/objects/baseline.py +44 -43
  31. contentctl/objects/baseline_tags.py +69 -20
  32. contentctl/objects/config.py +857 -125
  33. contentctl/objects/constants.py +0 -1
  34. contentctl/objects/correlation_search.py +1 -1
  35. contentctl/objects/data_source.py +2 -4
  36. contentctl/objects/deployment.py +61 -21
  37. contentctl/objects/deployment_email.py +2 -2
  38. contentctl/objects/deployment_notable.py +4 -4
  39. contentctl/objects/deployment_phantom.py +2 -2
  40. contentctl/objects/deployment_rba.py +3 -4
  41. contentctl/objects/deployment_scheduling.py +2 -3
  42. contentctl/objects/deployment_slack.py +2 -2
  43. contentctl/objects/detection.py +1 -5
  44. contentctl/objects/detection_tags.py +210 -119
  45. contentctl/objects/enums.py +312 -24
  46. contentctl/objects/integration_test.py +1 -1
  47. contentctl/objects/integration_test_result.py +0 -2
  48. contentctl/objects/investigation.py +62 -53
  49. contentctl/objects/investigation_tags.py +30 -6
  50. contentctl/objects/lookup.py +80 -31
  51. contentctl/objects/macro.py +29 -45
  52. contentctl/objects/mitre_attack_enrichment.py +29 -5
  53. contentctl/objects/observable.py +3 -7
  54. contentctl/objects/playbook.py +60 -30
  55. contentctl/objects/playbook_tags.py +45 -8
  56. contentctl/objects/security_content_object.py +1 -5
  57. contentctl/objects/ssa_detection.py +8 -4
  58. contentctl/objects/ssa_detection_tags.py +19 -26
  59. contentctl/objects/story.py +142 -44
  60. contentctl/objects/story_tags.py +46 -33
  61. contentctl/objects/unit_test.py +7 -2
  62. contentctl/objects/unit_test_attack_data.py +10 -19
  63. contentctl/objects/unit_test_baseline.py +1 -1
  64. contentctl/objects/unit_test_old.py +4 -3
  65. contentctl/objects/unit_test_result.py +5 -3
  66. contentctl/objects/unit_test_ssa.py +31 -0
  67. contentctl/output/api_json_output.py +202 -130
  68. contentctl/output/attack_nav_output.py +20 -9
  69. contentctl/output/attack_nav_writer.py +3 -3
  70. contentctl/output/ba_yml_output.py +3 -3
  71. contentctl/output/conf_output.py +125 -391
  72. contentctl/output/conf_writer.py +169 -31
  73. contentctl/output/jinja_writer.py +2 -2
  74. contentctl/output/json_writer.py +17 -5
  75. contentctl/output/new_content_yml_output.py +8 -7
  76. contentctl/output/svg_output.py +17 -27
  77. contentctl/output/templates/analyticstories_detections.j2 +8 -4
  78. contentctl/output/templates/analyticstories_investigations.j2 +1 -1
  79. contentctl/output/templates/analyticstories_stories.j2 +6 -6
  80. contentctl/output/templates/app.conf.j2 +2 -2
  81. contentctl/output/templates/app.manifest.j2 +2 -2
  82. contentctl/output/templates/detection_coverage.j2 +6 -8
  83. contentctl/output/templates/doc_detection_page.j2 +2 -2
  84. contentctl/output/templates/doc_detections.j2 +2 -2
  85. contentctl/output/templates/doc_stories.j2 +1 -1
  86. contentctl/output/templates/es_investigations_investigations.j2 +1 -1
  87. contentctl/output/templates/es_investigations_stories.j2 +1 -1
  88. contentctl/output/templates/header.j2 +2 -1
  89. contentctl/output/templates/macros.j2 +6 -10
  90. contentctl/output/templates/savedsearches_baselines.j2 +5 -5
  91. contentctl/output/templates/savedsearches_detections.j2 +36 -33
  92. contentctl/output/templates/savedsearches_investigations.j2 +4 -4
  93. contentctl/output/templates/transforms.j2 +4 -4
  94. contentctl/output/yml_writer.py +2 -2
  95. contentctl/templates/app_template/README.md +7 -0
  96. contentctl/{output/templates/splunk_app → templates/app_template}/default/data/ui/nav/default.xml +1 -0
  97. contentctl/templates/app_template/lookups/mitre_enrichment.csv +638 -0
  98. contentctl/templates/deployments/{00_default_anomaly.yml → escu_default_configuration_anomaly.yml} +1 -2
  99. contentctl/templates/deployments/{00_default_baseline.yml → escu_default_configuration_baseline.yml} +1 -2
  100. contentctl/templates/deployments/{00_default_correlation.yml → escu_default_configuration_correlation.yml} +2 -2
  101. contentctl/templates/deployments/{00_default_hunting.yml → escu_default_configuration_hunting.yml} +2 -2
  102. contentctl/templates/deployments/{00_default_ttp.yml → escu_default_configuration_ttp.yml} +1 -2
  103. contentctl/templates/detections/anomalous_usage_of_7zip.yml +0 -1
  104. contentctl/templates/stories/cobalt_strike.yml +0 -1
  105. {contentctl-3.6.0.dist-info → contentctl-4.0.2.dist-info}/METADATA +36 -15
  106. contentctl-4.0.2.dist-info/RECORD +168 -0
  107. contentctl/actions/detection_testing/DataManipulation.py +0 -149
  108. contentctl/actions/generate.py +0 -91
  109. contentctl/helper/config_handler.py +0 -75
  110. contentctl/input/baseline_builder.py +0 -66
  111. contentctl/input/basic_builder.py +0 -58
  112. contentctl/input/detection_builder.py +0 -370
  113. contentctl/input/investigation_builder.py +0 -42
  114. contentctl/input/new_content_generator.py +0 -95
  115. contentctl/input/playbook_builder.py +0 -68
  116. contentctl/input/story_builder.py +0 -106
  117. contentctl/objects/app.py +0 -214
  118. contentctl/objects/repo_config.py +0 -163
  119. contentctl/objects/test_config.py +0 -630
  120. contentctl/output/templates/macros_detections.j2 +0 -7
  121. contentctl/output/templates/splunk_app/README.md +0 -7
  122. contentctl-3.6.0.dist-info/RECORD +0 -176
  123. /contentctl/{output/templates/splunk_app → templates/app_template}/README/essoc_story_detail.txt +0 -0
  124. /contentctl/{output/templates/splunk_app → templates/app_template}/README/essoc_summary.txt +0 -0
  125. /contentctl/{output/templates/splunk_app → templates/app_template}/README/essoc_usage_dashboard.txt +0 -0
  126. /contentctl/{output/templates/splunk_app → templates/app_template}/default/analytic_stories.conf +0 -0
  127. /contentctl/{output/templates/splunk_app → templates/app_template}/default/app.conf +0 -0
  128. /contentctl/{output/templates/splunk_app → templates/app_template}/default/commands.conf +0 -0
  129. /contentctl/{output/templates/splunk_app → templates/app_template}/default/content-version.conf +0 -0
  130. /contentctl/{output/templates/splunk_app → templates/app_template}/default/data/ui/views/escu_summary.xml +0 -0
  131. /contentctl/{output/templates/splunk_app → templates/app_template}/default/data/ui/views/feedback.xml +0 -0
  132. /contentctl/{output/templates/splunk_app → templates/app_template}/default/distsearch.conf +0 -0
  133. /contentctl/{output/templates/splunk_app → templates/app_template}/default/usage_searches.conf +0 -0
  134. /contentctl/{output/templates/splunk_app → templates/app_template}/default/use_case_library.conf +0 -0
  135. /contentctl/{output/templates/splunk_app → templates/app_template}/metadata/default.meta +0 -0
  136. /contentctl/{output/templates/splunk_app → templates/app_template}/static/appIcon.png +0 -0
  137. /contentctl/{output/templates/splunk_app → templates/app_template}/static/appIconAlt.png +0 -0
  138. /contentctl/{output/templates/splunk_app → templates/app_template}/static/appIconAlt_2x.png +0 -0
  139. /contentctl/{output/templates/splunk_app → templates/app_template}/static/appIcon_2x.png +0 -0
  140. {contentctl-3.6.0.dist-info → contentctl-4.0.2.dist-info}/LICENSE.md +0 -0
  141. {contentctl-3.6.0.dist-info → contentctl-4.0.2.dist-info}/WHEEL +0 -0
  142. {contentctl-3.6.0.dist-info → contentctl-4.0.2.dist-info}/entry_points.txt +0 -0
@@ -1,71 +1,175 @@
1
1
  from __future__ import annotations
2
+ from typing import TYPE_CHECKING, Self
3
+
4
+ if TYPE_CHECKING:
5
+ from contentctl.objects.deployment import Deployment
6
+ from contentctl.objects.security_content_object import SecurityContentObject
7
+ from contentctl.objects.config import Config
8
+ from contentctl.input.director import DirectorOutputDto
9
+
10
+ from contentctl.objects.enums import AnalyticsType
2
11
  import re
3
12
  import abc
4
- import string
5
- import uuid
6
- from datetime import datetime
7
- from pydantic import BaseModel, validator, ValidationError, Field
8
- from contentctl.objects.enums import SecurityContentType
9
- from typing import Tuple
10
-
11
13
  import uuid
14
+ import datetime
15
+ from pydantic import BaseModel, field_validator, Field, ValidationInfo, FilePath, HttpUrl, NonNegativeInt, ConfigDict, model_validator, model_serializer
16
+ from typing import Tuple, Optional, List, Union
12
17
  import pathlib
18
+
19
+
20
+
21
+
22
+
23
+ NO_FILE_NAME = "NO_FILE_NAME"
24
+
13
25
 
14
- NO_FILE_BUILT_AT_RUNTIME = "NO_FILE_BUILT_AT_RUNTIME"
15
26
  class SecurityContentObject_Abstract(BaseModel, abc.ABC):
16
- #contentType: SecurityContentType
17
- name: str
18
- author: str = "UNKNOWN_AUTHOR"
19
- date: str = "1990-01-01"
20
- version: int = 1
27
+ model_config = ConfigDict(use_enum_values=True,validate_default=True)
28
+ # name: str = ...
29
+ # author: str = Field(...,max_length=255)
30
+ # date: datetime.date = Field(...)
31
+ # version: NonNegativeInt = ...
32
+ # id: uuid.UUID = Field(default_factory=uuid.uuid4) #we set a default here until all content has a uuid
33
+ # description: str = Field(...,max_length=1000)
34
+ # file_path: FilePath = Field(...)
35
+ # references: Optional[List[HttpUrl]] = None
36
+
37
+ name: str = Field("NO_NAME")
38
+ author: str = Field("Content Author",max_length=255)
39
+ date: datetime.date = Field(datetime.date.today())
40
+ version: NonNegativeInt = 1
21
41
  id: uuid.UUID = Field(default_factory=uuid.uuid4) #we set a default here until all content has a uuid
22
- description: str = "UNKNOWN_DESCRIPTION"
23
- file_path: str = "NO_FILE_BUILT_AT_RUNTIME"
42
+ description: str = Field("Enter Description Here",max_length=10000)
43
+ file_path: Optional[FilePath] = None
44
+ references: Optional[List[HttpUrl]] = None
24
45
 
25
- @validator('name')
26
- def name_max_length(cls, v):
27
- if len(v) > 67:
28
- raise ValueError('name is longer then 67 chars: ' + v)
29
- return v
30
46
 
31
- @validator('name')
32
- def name_invalid_chars(cls, v):
33
- invalidChars = set(string.punctuation.replace("-", ""))
34
- if any(char in invalidChars for char in v):
35
- raise ValueError('invalid chars used in name: ' + v)
36
- return v
47
+ @model_serializer
48
+ def serialize_model(self):
49
+ return {
50
+ "name": self.name,
51
+ "author": self.author,
52
+ "date": str(self.date),
53
+ "version": self.version,
54
+ "id": str(self.id),
55
+ "description": self.description,
56
+ "references": [str(url) for url in self.references or []]
57
+ }
58
+
59
+ @staticmethod
60
+ def objectListToNameList(objects:list[SecurityContentObject], config:Optional[Config]=None)->list[str]:
61
+ return [object.getName(config) for object in objects]
62
+
63
+ # This function is overloadable by specific types if they want to redefine names, for example
64
+ # to have the format ESCU - NAME - Rule (config.tag - self.name - Rule)
65
+ def getName(self, config:Optional[Config])->str:
66
+ return self.name
67
+
68
+
69
+ @classmethod
70
+ def contentNameToFileName(cls, content_name:str)->str:
71
+ return content_name \
72
+ .replace(' ', '_') \
73
+ .replace('-','_') \
74
+ .replace('.','_') \
75
+ .replace('/','_') \
76
+ .lower() + ".yml"
77
+
78
+
79
+ @model_validator(mode="after")
80
+ def ensureFileNameMatchesSearchName(self):
81
+ file_name = self.contentNameToFileName(self.name)
82
+
83
+ if (self.file_path is not None and file_name != self.file_path.name):
84
+ raise ValueError(f"The file name MUST be based off the content 'name' field:\n"\
85
+ f"\t- Expected File Name: {file_name}\n"\
86
+ f"\t- Actual File Name : {self.file_path.name}")
87
+
88
+ return self
37
89
 
38
- @validator('date')
39
- def date_valid(cls, v, values):
40
- try:
41
- datetime.strptime(v, "%Y-%m-%d")
42
- except:
43
- raise ValueError('date is not in format YYYY-MM-DD: ' + values["name"])
90
+ @field_validator('file_path')
91
+ @classmethod
92
+ def file_path_valid(cls, v: Optional[pathlib.PosixPath], info: ValidationInfo):
93
+ if not v:
94
+ #It's possible that the object has no file path - for example filter macros that are created at runtime
95
+ return v
96
+ if not v.name.endswith(".yml"):
97
+ raise ValueError(f"All Security Content Objects must be YML files and end in .yml. The following file does not: '{v}'")
44
98
  return v
45
99
 
46
- @staticmethod
47
- def free_text_field_valid(input_cls, v, values, field):
48
- try:
49
- v.encode('ascii')
50
- except UnicodeEncodeError as e:
51
- print(f"Potential Ascii encoding error in {values['name']}:{field.name} - {str(e)}")
52
- except Exception as e:
53
- print(f"Unknown encoding error in {values['name']}:{field.name} - {str(e)}")
100
+ def getReferencesListForJson(self)->List[str]:
101
+ return [str(url) for url in self.references or []]
54
102
 
103
+ @classmethod
104
+ def mapNamesToSecurityContentObjects(cls, v: list[str], director:Union[DirectorOutputDto,None])->list[Self]:
105
+ if director is not None:
106
+ name_map = director.name_to_content_map
107
+ else:
108
+ name_map = {}
55
109
 
56
- if bool(re.search(r"[^\\]\n", v)):
57
- raise ValueError(f"Unexpected newline(s) in {values['name']}:{field.name}. Newline characters MUST be prefixed with \\")
58
- return v
59
-
60
-
61
- @validator("name", "author", 'description')
62
- def description_valid(cls, v, values, field):
110
+
111
+
112
+ mappedObjects: list[Self] = []
113
+ mistyped_objects: list[SecurityContentObject_Abstract] = []
114
+ missing_objects: list[str] = []
115
+ for object_name in v:
116
+ found_object = name_map.get(object_name,None)
117
+ if not found_object:
118
+ missing_objects.append(object_name)
119
+ elif not isinstance(found_object,cls):
120
+ mistyped_objects.append(found_object)
121
+ else:
122
+ mappedObjects.append(found_object)
63
123
 
64
- return SecurityContentObject_Abstract.free_text_field_valid(cls,v,values,field)
65
-
124
+ errors:list[str] = []
125
+ if len(missing_objects) > 0:
126
+ errors.append(f"Failed to find the following '{cls.__name__}': {missing_objects}")
127
+ if len(missing_objects) > 0:
128
+ for mistyped_object in mistyped_objects:
129
+ errors.append(f"'{mistyped_object.name}' expected to have type '{type(Self)}', but actually had type '{type(mistyped_object)}'")
130
+
131
+ if len(errors) > 0:
132
+ error_string = "\n - ".join(errors)
133
+ raise ValueError(f"Found {len(errors)} issues when resolving references Security Content Object names:\n - {error_string}")
134
+
135
+ #Sort all objects sorted by name
136
+ return sorted(mappedObjects, key=lambda o: o.name)
137
+
138
+ @staticmethod
139
+ def getDeploymentFromType(typeField:Union[str,None], info:ValidationInfo)->Deployment:
140
+ if typeField is None:
141
+ raise ValueError("'type:' field is missing from YML.")
142
+ director: Optional[DirectorOutputDto] = info.context.get("output_dto",None)
143
+ if not director:
144
+ raise ValueError("Cannot set deployment - DirectorOutputDto not passed to Detection Constructor in context")
145
+
146
+ type_to_deployment_name_map = {AnalyticsType.TTP.value:"ESCU Default Configuration TTP",
147
+ AnalyticsType.Hunting.value:"ESCU Default Configuration Hunting",
148
+ AnalyticsType.Correlation.value: "ESCU Default Configuration Correlation",
149
+ AnalyticsType.Anomaly.value: "ESCU Default Configuration Anomaly",
150
+ "Baseline": "ESCU Default Configuration Baseline",
151
+ }
152
+ converted_type_field = type_to_deployment_name_map[typeField]
153
+
154
+ #TODO: This is clunky, but is imported here to resolve some circular import errors
155
+ from contentctl.objects.deployment import Deployment
156
+
157
+ deployments = Deployment.mapNamesToSecurityContentObjects([converted_type_field], director)
158
+ if len(deployments) == 1:
159
+ return deployments[0]
160
+ elif len(deployments) == 0:
161
+ raise ValueError(f"Failed to find Deployment for type '{converted_type_field}' "\
162
+ f"from possible {[deployment.type for deployment in director.deployments]}")
163
+ else:
164
+ raise ValueError(f"Found more than 1 ({len(deployments)}) Deployment for type '{converted_type_field}' "\
165
+ f"from possible {[deployment.type for deployment in director.deployments]}")
166
+
167
+
168
+
66
169
 
67
170
  @staticmethod
68
171
  def get_objects_by_name(names_to_find:set[str], objects_to_search:list[SecurityContentObject_Abstract])->Tuple[list[SecurityContentObject_Abstract], set[str]]:
172
+ raise Exception("get_objects_by_name deprecated")
69
173
  found_objects = list(filter(lambda obj: obj.name in names_to_find, objects_to_search))
70
174
  found_names = set([obj.name for obj in found_objects])
71
175
  missing_names = names_to_find - found_names
@@ -74,10 +178,10 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
74
178
  @staticmethod
75
179
  def create_filename_to_content_dict(all_objects:list[SecurityContentObject_Abstract])->dict[str,SecurityContentObject_Abstract]:
76
180
  name_dict:dict[str,SecurityContentObject_Abstract] = dict()
77
-
78
181
  for object in all_objects:
79
182
  name_dict[str(pathlib.Path(object.file_path))] = object
80
-
81
183
  return name_dict
82
184
 
185
+
186
+
83
187
 
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+ from pydantic import BaseModel, model_serializer
3
+ from typing import Optional
4
+
5
+ from contentctl.objects.deployment_email import DeploymentEmail
6
+ from contentctl.objects.deployment_notable import DeploymentNotable
7
+ from contentctl.objects.deployment_rba import DeploymentRBA
8
+ from contentctl.objects.deployment_slack import DeploymentSlack
9
+ from contentctl.objects.deployment_phantom import DeploymentPhantom
10
+
11
+ class AlertAction(BaseModel):
12
+ email: Optional[DeploymentEmail] = None
13
+ notable: Optional[DeploymentNotable] = None
14
+ rba: Optional[DeploymentRBA] = DeploymentRBA()
15
+ slack: Optional[DeploymentSlack] = None
16
+ phantom: Optional[DeploymentPhantom] = None
17
+
18
+
19
+ @model_serializer
20
+ def serialize_model(self):
21
+ #Call serializer for parent
22
+ model = {}
23
+
24
+ if self.email is not None:
25
+ raise Exception("Email not implemented")
26
+
27
+ if self.notable is not None:
28
+ model['notable'] = self.notable
29
+
30
+ if self.rba is not None and self.rba.enabled:
31
+ model['rba'] = {'enabled': "true"}
32
+
33
+ if self.slack is not None:
34
+ raise Exception("Slack not implemented")
35
+
36
+ if self.phantom is not None:
37
+ raise Exception("Phantom not implemented")
38
+
39
+ #return the model
40
+ return model
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+ from contentctl.input.yml_reader import YmlReader
3
+ from pydantic import BaseModel, model_validator, ConfigDict, FilePath, UUID4
4
+ from typing import List, Optional, Dict, Union, Self
5
+ import pathlib
6
+ # We should determine if we want to use StrEnum, which is only present in Python3.11+
7
+ # Alternatively, we can use
8
+ # class SupportedPlatform(str, enum.Enum):
9
+ # or install the StrEnum library from pip
10
+
11
+ from enum import StrEnum, auto
12
+
13
+
14
+ class SupportedPlatform(StrEnum):
15
+ windows = auto()
16
+ linux = auto()
17
+ macos = auto()
18
+ containers = auto()
19
+ # Because the following fields contain special characters
20
+ # (which cannot be field names) we must specifiy them manually
21
+ google_workspace = "google-workspace"
22
+ iaas_gcp = "iaas:gcp"
23
+ iaas_azure = "iaas:azure"
24
+ iaas_aws = "iaas:aws"
25
+ azure_ad = "azure-ad"
26
+ office_365 = "office-365"
27
+
28
+
29
+
30
+ class InputArgumentType(StrEnum):
31
+ string = auto()
32
+ path = auto()
33
+ url = auto()
34
+ integer = auto()
35
+ float = auto()
36
+ # Cannot use auto() since the case sensitivity is important
37
+ # These should likely be converted in the ART repo to use the same case
38
+ # As the defined types above
39
+ String = "String"
40
+ Path = "Path"
41
+ Url = "Url"
42
+
43
+ class AtomicExecutor(BaseModel):
44
+ name: str
45
+ elevation_required: Optional[bool] = False #Appears to be optional
46
+ command: Optional[str] = None
47
+ steps: Optional[str] = None
48
+ cleanup_command: Optional[str] = None
49
+
50
+ @model_validator(mode='after')
51
+ def ensure_mutually_exclusive_fields(self)->AtomicExecutor:
52
+ if self.command is not None and self.steps is not None:
53
+ raise ValueError("command and steps cannot both be defined in the executor section. Exactly one must be defined.")
54
+ elif self.command is None and self.steps is None:
55
+ raise ValueError("Neither command nor steps were defined in the executor section. Exactly one must be defined.")
56
+ return self
57
+
58
+
59
+
60
+ class InputArgument(BaseModel):
61
+ model_config = ConfigDict(extra='forbid')
62
+ description: str
63
+ type: InputArgumentType
64
+ default: Union[str,int,float,None] = None
65
+
66
+
67
+ class DependencyExecutorType(StrEnum):
68
+ powershell = auto()
69
+ sh = auto()
70
+ bash = auto()
71
+ command_prompt = auto()
72
+
73
+ class AtomicDependency(BaseModel):
74
+ model_config = ConfigDict(extra='forbid')
75
+ description: str
76
+ prereq_command: str
77
+ get_prereq_command: str
78
+
79
+ class AtomicTest(BaseModel):
80
+ model_config = ConfigDict(extra='forbid')
81
+ name: str
82
+ auto_generated_guid: UUID4
83
+ description: str
84
+ supported_platforms: List[SupportedPlatform]
85
+ executor: AtomicExecutor
86
+ input_arguments: Optional[Dict[str,InputArgument]] = None
87
+ dependencies: Optional[List[AtomicDependency]] = None
88
+ dependency_executor_name: Optional[DependencyExecutorType] = None
89
+
90
+ @staticmethod
91
+ def AtomicTestWhenEnrichmentIsDisabled(auto_generated_guid: UUID4)->Self:
92
+ return AtomicTest(name="Placeholder Atomic Test (enrichment disabled)",
93
+ auto_generated_guid=auto_generated_guid,
94
+ description="This is a placeholder AtomicTest. Because enrichments were not enabled, it has not been validated against the real Atomic Red Team Repo.",
95
+ supported_platforms=[],
96
+ executor=AtomicExecutor(name="Placeholder Executor (enrichment disabled)",
97
+ command="Placeholder command (enrichment disabled)"))
98
+
99
+ @staticmethod
100
+ def AtomicTestWhenTestIsMissing(auto_generated_guid: UUID4)->Self:
101
+ return AtomicTest(name="Missing Atomic",
102
+ auto_generated_guid=auto_generated_guid,
103
+ description="This is a placeholder AtomicTest. Either the auto_generated_guid is incorrect or it there was an exception while parsing its AtomicFile..",
104
+ supported_platforms=[],
105
+ executor=AtomicExecutor(name="Placeholder Executor (failed to find auto_generated_guid)",
106
+ command="Placeholder command (failed to find auto_generated_guid)"))
107
+
108
+
109
+ @classmethod
110
+ def getAtomicByAtomicGuid(cls, guid: UUID4, all_atomics:Union[List[AtomicTest],None])->AtomicTest:
111
+ if all_atomics is None:
112
+ return AtomicTest.AtomicTestWhenEnrichmentIsDisabled(guid)
113
+ matching_atomics = [atomic for atomic in all_atomics if atomic.auto_generated_guid == guid]
114
+ if len(matching_atomics) == 0:
115
+ raise ValueError(f"Unable to find atomic_guid {guid} in {len(all_atomics)} atomic_tests from ART Repo")
116
+ elif len(matching_atomics) > 1:
117
+ raise ValueError(f"Found {len(matching_atomics)} matching tests for atomic_guid {guid} in {len(all_atomics)} atomic_tests from ART Repo")
118
+
119
+ return matching_atomics[0]
120
+
121
+ @classmethod
122
+ def parseArtRepo(cls, repo_path:pathlib.Path)->List[AtomicFile]:
123
+ if not repo_path.is_dir():
124
+ print(f"WARNING: Atomic Red Team repo does NOT exist at {repo_path.absolute()}. You can check it out with:\n * git clone --single-branch https://github.com/redcanaryco/atomic-red-team. This will ONLY throw a validation error if you reference atomid_guids in your detection(s).")
125
+ return []
126
+ atomics_path = repo_path/"atomics"
127
+ if not atomics_path.is_dir():
128
+ print(f"WARNING: Atomic Red Team repo exists at {repo_path.absolute}, but atomics directory does NOT exist at {atomics_path.absolute()}. Was it deleted or renamed? This will ONLY throw a validation error if you reference atomid_guids in your detection(s).")
129
+ return []
130
+
131
+
132
+ atomic_files:List[AtomicFile] = []
133
+ error_messages:List[str] = []
134
+ for obj_path in atomics_path.glob("**/T*.yaml"):
135
+ try:
136
+ atomic_files.append(cls.constructAtomicFile(obj_path))
137
+ except Exception as e:
138
+ error_messages.append(f"File [{obj_path}]\n{str(e)}")
139
+ if len(error_messages) > 0:
140
+ exceptions_string = '\n\n'.join(error_messages)
141
+ print(f"WARNING: The following [{len(error_messages)}] ERRORS were generated when parsing the Atomic Red Team Repo.\n"
142
+ "Please raise an issue so that they can be fixed at https://github.com/redcanaryco/atomic-red-team/issues.\n"
143
+ "Note that this is only a warning and contentctl will ignore Atomics contained in these files.\n"
144
+ f"However, if you have written a detection that references them, 'contentctl build --enrichments' will fail:\n\n{exceptions_string}")
145
+
146
+ return atomic_files
147
+
148
+ @classmethod
149
+ def constructAtomicFile(cls, file_path:pathlib.Path)->AtomicFile:
150
+ yml_dict = YmlReader.load_file(file_path)
151
+ atomic_file = AtomicFile.model_validate(yml_dict)
152
+ return atomic_file
153
+
154
+ @classmethod
155
+ def getAtomicTestsFromArtRepo(cls, repo_path:pathlib.Path, enabled:bool=True)->Union[List[AtomicTest],None]:
156
+ # Get all the atomic files. Note that if the ART repo is not found, we will not throw an error,
157
+ # but will not have any atomics. This means that if atomic_guids are referenced during validation,
158
+ # validation for those detections will fail
159
+ if not enabled:
160
+ return None
161
+
162
+ atomic_files = cls.getAtomicFilesFromArtRepo(repo_path)
163
+
164
+ atomic_tests:List[AtomicTest] = []
165
+ for atomic_file in atomic_files:
166
+ atomic_tests.extend(atomic_file.atomic_tests)
167
+ print(f"Found [{len(atomic_tests)}] Atomic Simulations in the Atomic Red Team Repo!")
168
+ return atomic_tests
169
+
170
+
171
+ @classmethod
172
+ def getAtomicFilesFromArtRepo(cls, repo_path:pathlib.Path)->List[AtomicFile]:
173
+ return cls.parseArtRepo(repo_path)
174
+
175
+
176
+
177
+
178
+
179
+
180
+ class AtomicFile(BaseModel):
181
+ model_config = ConfigDict(extra='forbid')
182
+ file_path: FilePath
183
+ attack_technique: str
184
+ display_name: str
185
+ atomic_tests: List[AtomicTest]
186
+
187
+
188
+
189
+
190
+ # ATOMICS_PATH = pathlib.Path("./atomics")
191
+ # atomic_objects = []
192
+ # atomic_simulations = []
193
+ # for obj_path in ATOMICS_PATH.glob("**/T*.yaml"):
194
+ # try:
195
+ # with open(obj_path, 'r', encoding="utf-8") as obj_handle:
196
+ # obj_data = yaml.load(obj_handle, Loader=yaml.CSafeLoader)
197
+ # atomic_obj = AtomicFile.model_validate(obj_data)
198
+ # except Exception as e:
199
+ # print(f"Error parsing object at path {obj_path}: {str(e)}")
200
+ # print(f"We have successfully parsed {len(atomic_objects)}, however!")
201
+ # sys.exit(1)
202
+
203
+ # print(f"Successfully parsed {obj_path}!")
204
+ # atomic_objects.append(atomic_obj)
205
+ # atomic_simulations += atomic_obj.atomic_tests
206
+
207
+ # print(f"Successfully parsed all {len(atomic_objects)} files!")
208
+ # print(f"Successfully parsed all {len(atomic_simulations)} simulations!")
209
+
210
+
211
+
212
+
@@ -1,17 +1,21 @@
1
- import string
2
- import uuid
3
- import requests
4
1
 
5
- from pydantic import BaseModel, validator, ValidationError
6
- from dataclasses import dataclass
7
- from datetime import datetime
2
+ from __future__ import annotations
3
+ from typing import TYPE_CHECKING, Annotated, Optional, List,Any
4
+ from pydantic import field_validator, ValidationInfo, Field, model_serializer
5
+ if TYPE_CHECKING:
6
+ from contentctl.input.director import DirectorOutputDto
8
7
 
8
+ from contentctl.objects.deployment import Deployment
9
9
  from contentctl.objects.security_content_object import SecurityContentObject
10
- from contentctl.objects.enums import DataModel
10
+ from contentctl.objects.enums import DataModel, AnalyticsType
11
11
  from contentctl.objects.baseline_tags import BaselineTags
12
- from contentctl.objects.deployment import Deployment
13
- from contentctl.helper.link_validator import LinkValidator
14
- from contentctl.objects.enums import SecurityContentType
12
+ from contentctl.objects.enums import DeploymentType
13
+ #from contentctl.objects.deployment import Deployment
14
+
15
+ # from typing import TYPE_CHECKING
16
+ # if TYPE_CHECKING:
17
+ # from contentctl.input.director import DirectorOutputDto
18
+
15
19
 
16
20
  class Baseline(SecurityContentObject):
17
21
  # baseline spec
@@ -21,43 +25,40 @@ class Baseline(SecurityContentObject):
21
25
  #date: str
22
26
  #author: str
23
27
  #contentType: SecurityContentType = SecurityContentType.baselines
24
- type: str
25
- datamodel: list
28
+ type: Annotated[str,Field(pattern="^Baseline$")] = Field(...)
29
+ datamodel: Optional[List[DataModel]] = None
26
30
  #description: str
27
- search: str
28
- how_to_implement: str
29
- known_false_positives: str
31
+ search: str = Field(..., min_length=4)
32
+ how_to_implement: str = Field(..., min_length=4)
33
+ known_false_positives: str = Field(..., min_length=4)
30
34
  check_references: bool = False #Validation is done in order, this field must be defined first
31
- references: list
32
- tags: BaselineTags
35
+ tags: BaselineTags = Field(...)
33
36
 
34
37
  # enrichment
35
- deployment: Deployment = None
36
-
38
+ deployment: Deployment = Field({})
37
39
 
40
+ @field_validator("deployment", mode="before")
41
+ def getDeployment(cls, v:Any, info:ValidationInfo)->Deployment:
42
+ return Deployment.getDeployment(v,info)
43
+
38
44
 
39
-
40
- @validator('type')
41
- def type_valid(cls, v, values):
42
- if v != "Baseline":
43
- raise ValueError('not valid analytics type: ' + values["name"])
44
- return v
45
-
46
- @validator('datamodel')
47
- def datamodel_valid(cls, v, values):
48
- for datamodel in v:
49
- if datamodel not in [el.name for el in DataModel]:
50
- raise ValueError('not valid data model: ' + values["name"])
51
- return v
52
-
53
- @validator('how_to_implement')
54
- def encode_error(cls, v, values, field):
55
- return SecurityContentObject.free_text_field_valid(cls,v,values,field)
45
+ @model_serializer
46
+ def serialize_model(self):
47
+ #Call serializer for parent
48
+ super_fields = super().serialize_model()
49
+
50
+ #All fields custom to this model
51
+ model= {
52
+ "tags": self.tags.model_dump(),
53
+ "type": self.type,
54
+ "search": self.search,
55
+ "how_to_implement":self.how_to_implement,
56
+ "known_false_positives":self.known_false_positives,
57
+ "datamodel": self.datamodel,
58
+ }
59
+
60
+ #Combine fields from this model with fields from parent
61
+ super_fields.update(model)
56
62
 
57
- # @validator('references')
58
- # def references_check(cls, v, values):
59
- # return LinkValidator.SecurityContentObject_validate_references(v, values)
60
- @validator('search')
61
- def search_validate(cls, v, values):
62
- # write search validator
63
- return v
63
+ #return the model
64
+ return super_fields