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.
- contentctl/actions/build.py +89 -0
- contentctl/actions/detection_testing/DetectionTestingManager.py +48 -49
- contentctl/actions/detection_testing/GitService.py +148 -230
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +14 -24
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +43 -17
- contentctl/actions/detection_testing/views/DetectionTestingView.py +3 -2
- contentctl/actions/detection_testing/views/DetectionTestingViewFile.py +0 -8
- contentctl/actions/doc_gen.py +1 -1
- contentctl/actions/initialize.py +28 -65
- contentctl/actions/inspect.py +260 -0
- contentctl/actions/new_content.py +106 -13
- contentctl/actions/release_notes.py +168 -144
- contentctl/actions/reporting.py +24 -13
- contentctl/actions/test.py +39 -20
- contentctl/actions/validate.py +25 -48
- contentctl/contentctl.py +196 -754
- contentctl/enrichments/attack_enrichment.py +69 -19
- contentctl/enrichments/cve_enrichment.py +28 -13
- contentctl/helper/link_validator.py +24 -26
- contentctl/helper/utils.py +7 -3
- contentctl/input/director.py +139 -201
- contentctl/input/new_content_questions.py +63 -61
- contentctl/input/sigma_converter.py +1 -2
- contentctl/input/ssa_detection_builder.py +16 -7
- contentctl/input/yml_reader.py +4 -3
- contentctl/objects/abstract_security_content_objects/detection_abstract.py +487 -154
- contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +155 -51
- contentctl/objects/alert_action.py +40 -0
- contentctl/objects/atomic.py +212 -0
- contentctl/objects/baseline.py +44 -43
- contentctl/objects/baseline_tags.py +69 -20
- contentctl/objects/config.py +857 -125
- contentctl/objects/constants.py +0 -1
- contentctl/objects/correlation_search.py +1 -1
- contentctl/objects/data_source.py +2 -4
- contentctl/objects/deployment.py +61 -21
- contentctl/objects/deployment_email.py +2 -2
- contentctl/objects/deployment_notable.py +4 -4
- contentctl/objects/deployment_phantom.py +2 -2
- contentctl/objects/deployment_rba.py +3 -4
- contentctl/objects/deployment_scheduling.py +2 -3
- contentctl/objects/deployment_slack.py +2 -2
- contentctl/objects/detection.py +1 -5
- contentctl/objects/detection_tags.py +210 -119
- contentctl/objects/enums.py +312 -24
- contentctl/objects/integration_test.py +1 -1
- contentctl/objects/integration_test_result.py +0 -2
- contentctl/objects/investigation.py +62 -53
- contentctl/objects/investigation_tags.py +30 -6
- contentctl/objects/lookup.py +80 -31
- contentctl/objects/macro.py +29 -45
- contentctl/objects/mitre_attack_enrichment.py +29 -5
- contentctl/objects/observable.py +3 -7
- contentctl/objects/playbook.py +60 -30
- contentctl/objects/playbook_tags.py +45 -8
- contentctl/objects/security_content_object.py +1 -5
- contentctl/objects/ssa_detection.py +8 -4
- contentctl/objects/ssa_detection_tags.py +19 -26
- contentctl/objects/story.py +142 -44
- contentctl/objects/story_tags.py +46 -33
- contentctl/objects/unit_test.py +7 -2
- contentctl/objects/unit_test_attack_data.py +10 -19
- contentctl/objects/unit_test_baseline.py +1 -1
- contentctl/objects/unit_test_old.py +4 -3
- contentctl/objects/unit_test_result.py +5 -3
- contentctl/objects/unit_test_ssa.py +31 -0
- contentctl/output/api_json_output.py +202 -130
- contentctl/output/attack_nav_output.py +20 -9
- contentctl/output/attack_nav_writer.py +3 -3
- contentctl/output/ba_yml_output.py +3 -3
- contentctl/output/conf_output.py +125 -391
- contentctl/output/conf_writer.py +169 -31
- contentctl/output/jinja_writer.py +2 -2
- contentctl/output/json_writer.py +17 -5
- contentctl/output/new_content_yml_output.py +8 -7
- contentctl/output/svg_output.py +17 -27
- contentctl/output/templates/analyticstories_detections.j2 +8 -4
- contentctl/output/templates/analyticstories_investigations.j2 +1 -1
- contentctl/output/templates/analyticstories_stories.j2 +6 -6
- contentctl/output/templates/app.conf.j2 +2 -2
- contentctl/output/templates/app.manifest.j2 +2 -2
- contentctl/output/templates/detection_coverage.j2 +6 -8
- contentctl/output/templates/doc_detection_page.j2 +2 -2
- contentctl/output/templates/doc_detections.j2 +2 -2
- contentctl/output/templates/doc_stories.j2 +1 -1
- contentctl/output/templates/es_investigations_investigations.j2 +1 -1
- contentctl/output/templates/es_investigations_stories.j2 +1 -1
- contentctl/output/templates/header.j2 +2 -1
- contentctl/output/templates/macros.j2 +6 -10
- contentctl/output/templates/savedsearches_baselines.j2 +5 -5
- contentctl/output/templates/savedsearches_detections.j2 +36 -33
- contentctl/output/templates/savedsearches_investigations.j2 +4 -4
- contentctl/output/templates/transforms.j2 +4 -4
- contentctl/output/yml_writer.py +2 -2
- contentctl/templates/app_template/README.md +7 -0
- contentctl/{output/templates/splunk_app → templates/app_template}/default/data/ui/nav/default.xml +1 -0
- contentctl/templates/app_template/lookups/mitre_enrichment.csv +638 -0
- contentctl/templates/deployments/{00_default_anomaly.yml → escu_default_configuration_anomaly.yml} +1 -2
- contentctl/templates/deployments/{00_default_baseline.yml → escu_default_configuration_baseline.yml} +1 -2
- contentctl/templates/deployments/{00_default_correlation.yml → escu_default_configuration_correlation.yml} +2 -2
- contentctl/templates/deployments/{00_default_hunting.yml → escu_default_configuration_hunting.yml} +2 -2
- contentctl/templates/deployments/{00_default_ttp.yml → escu_default_configuration_ttp.yml} +1 -2
- contentctl/templates/detections/anomalous_usage_of_7zip.yml +0 -1
- contentctl/templates/stories/cobalt_strike.yml +0 -1
- {contentctl-3.6.0.dist-info → contentctl-4.0.2.dist-info}/METADATA +36 -15
- contentctl-4.0.2.dist-info/RECORD +168 -0
- contentctl/actions/detection_testing/DataManipulation.py +0 -149
- contentctl/actions/generate.py +0 -91
- contentctl/helper/config_handler.py +0 -75
- contentctl/input/baseline_builder.py +0 -66
- contentctl/input/basic_builder.py +0 -58
- contentctl/input/detection_builder.py +0 -370
- contentctl/input/investigation_builder.py +0 -42
- contentctl/input/new_content_generator.py +0 -95
- contentctl/input/playbook_builder.py +0 -68
- contentctl/input/story_builder.py +0 -106
- contentctl/objects/app.py +0 -214
- contentctl/objects/repo_config.py +0 -163
- contentctl/objects/test_config.py +0 -630
- contentctl/output/templates/macros_detections.j2 +0 -7
- contentctl/output/templates/splunk_app/README.md +0 -7
- contentctl-3.6.0.dist-info/RECORD +0 -176
- /contentctl/{output/templates/splunk_app → templates/app_template}/README/essoc_story_detail.txt +0 -0
- /contentctl/{output/templates/splunk_app → templates/app_template}/README/essoc_summary.txt +0 -0
- /contentctl/{output/templates/splunk_app → templates/app_template}/README/essoc_usage_dashboard.txt +0 -0
- /contentctl/{output/templates/splunk_app → templates/app_template}/default/analytic_stories.conf +0 -0
- /contentctl/{output/templates/splunk_app → templates/app_template}/default/app.conf +0 -0
- /contentctl/{output/templates/splunk_app → templates/app_template}/default/commands.conf +0 -0
- /contentctl/{output/templates/splunk_app → templates/app_template}/default/content-version.conf +0 -0
- /contentctl/{output/templates/splunk_app → templates/app_template}/default/data/ui/views/escu_summary.xml +0 -0
- /contentctl/{output/templates/splunk_app → templates/app_template}/default/data/ui/views/feedback.xml +0 -0
- /contentctl/{output/templates/splunk_app → templates/app_template}/default/distsearch.conf +0 -0
- /contentctl/{output/templates/splunk_app → templates/app_template}/default/usage_searches.conf +0 -0
- /contentctl/{output/templates/splunk_app → templates/app_template}/default/use_case_library.conf +0 -0
- /contentctl/{output/templates/splunk_app → templates/app_template}/metadata/default.meta +0 -0
- /contentctl/{output/templates/splunk_app → templates/app_template}/static/appIcon.png +0 -0
- /contentctl/{output/templates/splunk_app → templates/app_template}/static/appIconAlt.png +0 -0
- /contentctl/{output/templates/splunk_app → templates/app_template}/static/appIconAlt_2x.png +0 -0
- /contentctl/{output/templates/splunk_app → templates/app_template}/static/appIcon_2x.png +0 -0
- {contentctl-3.6.0.dist-info → contentctl-4.0.2.dist-info}/LICENSE.md +0 -0
- {contentctl-3.6.0.dist-info → contentctl-4.0.2.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
17
|
-
name: str
|
|
18
|
-
author: str =
|
|
19
|
-
date:
|
|
20
|
-
version:
|
|
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 = "
|
|
23
|
-
file_path:
|
|
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
|
-
@
|
|
32
|
-
def
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
@
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
+
|
contentctl/objects/baseline.py
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
|
-
import string
|
|
2
|
-
import uuid
|
|
3
|
-
import requests
|
|
4
1
|
|
|
5
|
-
from
|
|
6
|
-
from
|
|
7
|
-
from
|
|
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.
|
|
13
|
-
from contentctl.
|
|
14
|
-
|
|
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:
|
|
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
|
-
|
|
32
|
-
tags: BaselineTags
|
|
35
|
+
tags: BaselineTags = Field(...)
|
|
33
36
|
|
|
34
37
|
# enrichment
|
|
35
|
-
deployment: Deployment =
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|