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
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import shutil
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from contentctl.objects.enums import SecurityContentProduct, SecurityContentType
|
|
8
|
+
from contentctl.input.director import Director, DirectorOutputDto
|
|
9
|
+
from contentctl.output.conf_output import ConfOutput
|
|
10
|
+
from contentctl.output.conf_writer import ConfWriter
|
|
11
|
+
from contentctl.output.ba_yml_output import BAYmlOutput
|
|
12
|
+
from contentctl.output.api_json_output import ApiJsonOutput
|
|
13
|
+
import pathlib
|
|
14
|
+
import json
|
|
15
|
+
import datetime
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
from contentctl.objects.config import build
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class BuildInputDto:
|
|
22
|
+
director_output_dto: DirectorOutputDto
|
|
23
|
+
config:build
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Build:
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def execute(self, input_dto: BuildInputDto) -> DirectorOutputDto:
|
|
31
|
+
if input_dto.config.build_app:
|
|
32
|
+
updated_conf_files:set[pathlib.Path] = set()
|
|
33
|
+
conf_output = ConfOutput(input_dto.config)
|
|
34
|
+
updated_conf_files.update(conf_output.writeHeaders())
|
|
35
|
+
updated_conf_files.update(conf_output.writeObjects(input_dto.director_output_dto.detections, SecurityContentType.detections))
|
|
36
|
+
updated_conf_files.update(conf_output.writeObjects(input_dto.director_output_dto.stories, SecurityContentType.stories))
|
|
37
|
+
updated_conf_files.update(conf_output.writeObjects(input_dto.director_output_dto.baselines, SecurityContentType.baselines))
|
|
38
|
+
updated_conf_files.update(conf_output.writeObjects(input_dto.director_output_dto.investigations, SecurityContentType.investigations))
|
|
39
|
+
updated_conf_files.update(conf_output.writeObjects(input_dto.director_output_dto.lookups, SecurityContentType.lookups))
|
|
40
|
+
updated_conf_files.update(conf_output.writeObjects(input_dto.director_output_dto.macros, SecurityContentType.macros))
|
|
41
|
+
updated_conf_files.update(conf_output.writeAppConf())
|
|
42
|
+
|
|
43
|
+
#Ensure that the conf file we just generated/update is syntactically valid
|
|
44
|
+
for conf_file in updated_conf_files:
|
|
45
|
+
ConfWriter.validateConfFile(conf_file)
|
|
46
|
+
|
|
47
|
+
conf_output.packageApp()
|
|
48
|
+
|
|
49
|
+
print(f"Build of '{input_dto.config.app.title}' APP successful to {input_dto.config.getPackageFilePath()}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if input_dto.config.build_api:
|
|
53
|
+
shutil.rmtree(input_dto.config.getAPIPath(), ignore_errors=True)
|
|
54
|
+
input_dto.config.getAPIPath().mkdir(parents=True)
|
|
55
|
+
api_json_output = ApiJsonOutput()
|
|
56
|
+
for output_objects, output_type in [(input_dto.director_output_dto.detections, SecurityContentType.detections),
|
|
57
|
+
(input_dto.director_output_dto.stories, SecurityContentType.stories),
|
|
58
|
+
(input_dto.director_output_dto.baselines, SecurityContentType.baselines),
|
|
59
|
+
(input_dto.director_output_dto.investigations, SecurityContentType.investigations),
|
|
60
|
+
(input_dto.director_output_dto.lookups, SecurityContentType.lookups),
|
|
61
|
+
(input_dto.director_output_dto.macros, SecurityContentType.macros),
|
|
62
|
+
(input_dto.director_output_dto.deployments, SecurityContentType.deployments)]:
|
|
63
|
+
api_json_output.writeObjects(output_objects, input_dto.config.getAPIPath(), input_dto.config.app.label, output_type )
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
#create version file for sse api
|
|
68
|
+
version_file = input_dto.config.getAPIPath()/"version.json"
|
|
69
|
+
utc_time = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0,tzinfo=None).isoformat()
|
|
70
|
+
version_dict = {"version":{"name":f"v{input_dto.config.app.version}","published_at": f"{utc_time}Z" }}
|
|
71
|
+
with open(version_file,"w") as version_f:
|
|
72
|
+
json.dump(version_dict,version_f)
|
|
73
|
+
|
|
74
|
+
print(f"Build of '{input_dto.config.app.title}' API successful to {input_dto.config.getAPIPath()}")
|
|
75
|
+
|
|
76
|
+
if input_dto.config.build_ssa:
|
|
77
|
+
|
|
78
|
+
srs_path = input_dto.config.getSSAPath() / 'srs'
|
|
79
|
+
complex_path = input_dto.config.getSSAPath() / 'complex'
|
|
80
|
+
shutil.rmtree(srs_path, ignore_errors=True)
|
|
81
|
+
shutil.rmtree(complex_path, ignore_errors=True)
|
|
82
|
+
srs_path.mkdir(parents=True)
|
|
83
|
+
complex_path.mkdir(parents=True)
|
|
84
|
+
ba_yml_output = BAYmlOutput()
|
|
85
|
+
ba_yml_output.writeObjects(input_dto.director_output_dto.ssa_detections, str(input_dto.config.getSSAPath()))
|
|
86
|
+
|
|
87
|
+
print(f"Build of 'SSA' successful to {input_dto.config.getSSAPath()}")
|
|
88
|
+
|
|
89
|
+
return input_dto.director_output_dto
|
|
@@ -1,31 +1,15 @@
|
|
|
1
|
-
from
|
|
2
|
-
from contentctl.
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
from contentctl.actions.detection_testing.infrastructures.
|
|
6
|
-
DetectionTestingInfrastructureContainer,
|
|
7
|
-
)
|
|
8
|
-
from contentctl.actions.detection_testing.infrastructures.DetectionTestingInfrastructureServer import (
|
|
9
|
-
DetectionTestingInfrastructureServer,
|
|
10
|
-
)
|
|
11
|
-
|
|
12
|
-
from contentctl.objects.app import App
|
|
13
|
-
import pathlib
|
|
14
|
-
import os
|
|
15
|
-
from contentctl.helper.utils import Utils
|
|
1
|
+
from typing import List,Union
|
|
2
|
+
from contentctl.objects.config import test, test_servers, Container,Infrastructure
|
|
3
|
+
from contentctl.actions.detection_testing.infrastructures.DetectionTestingInfrastructure import DetectionTestingInfrastructure
|
|
4
|
+
from contentctl.actions.detection_testing.infrastructures.DetectionTestingInfrastructureContainer import DetectionTestingInfrastructureContainer
|
|
5
|
+
from contentctl.actions.detection_testing.infrastructures.DetectionTestingInfrastructureServer import DetectionTestingInfrastructureServer
|
|
16
6
|
from urllib.parse import urlparse
|
|
17
|
-
import time
|
|
18
7
|
from copy import deepcopy
|
|
19
8
|
from contentctl.objects.enums import DetectionTestingTargetInfrastructure
|
|
20
9
|
import signal
|
|
21
10
|
import datetime
|
|
22
|
-
|
|
23
11
|
# from queue import Queue
|
|
24
|
-
|
|
25
|
-
CONTAINER_APP_PATH = pathlib.Path("apps")
|
|
26
|
-
|
|
27
12
|
from dataclasses import dataclass
|
|
28
|
-
|
|
29
13
|
# import threading
|
|
30
14
|
import ctypes
|
|
31
15
|
from contentctl.actions.detection_testing.infrastructures.DetectionTestingInfrastructure import (
|
|
@@ -35,23 +19,17 @@ from contentctl.actions.detection_testing.infrastructures.DetectionTestingInfras
|
|
|
35
19
|
from contentctl.actions.detection_testing.views.DetectionTestingView import (
|
|
36
20
|
DetectionTestingView,
|
|
37
21
|
)
|
|
38
|
-
|
|
39
22
|
from contentctl.objects.enums import PostTestBehavior
|
|
40
|
-
|
|
41
23
|
from pydantic import BaseModel, Field
|
|
42
|
-
from contentctl.input.director import DirectorOutputDto
|
|
43
24
|
from contentctl.objects.detection import Detection
|
|
44
|
-
|
|
45
|
-
|
|
46
25
|
import concurrent.futures
|
|
47
|
-
|
|
48
|
-
import tqdm
|
|
26
|
+
import docker
|
|
49
27
|
|
|
50
28
|
|
|
51
29
|
@dataclass(frozen=False)
|
|
52
30
|
class DetectionTestingManagerInputDto:
|
|
53
|
-
config:
|
|
54
|
-
|
|
31
|
+
config: Union[test,test_servers]
|
|
32
|
+
detections: List[Detection]
|
|
55
33
|
views: list[DetectionTestingView]
|
|
56
34
|
|
|
57
35
|
|
|
@@ -67,7 +45,7 @@ class DetectionTestingManager(BaseModel):
|
|
|
67
45
|
|
|
68
46
|
# for content in self.input_dto.testContent.detections:
|
|
69
47
|
# self.pending_queue.put(content)
|
|
70
|
-
self.output_dto.inputQueue = self.input_dto.
|
|
48
|
+
self.output_dto.inputQueue = self.input_dto.detections
|
|
71
49
|
self.create_DetectionTestingInfrastructureObjects()
|
|
72
50
|
|
|
73
51
|
def execute(self) -> DetectionTestingManagerOutputDto:
|
|
@@ -87,13 +65,13 @@ class DetectionTestingManager(BaseModel):
|
|
|
87
65
|
print("*******************************")
|
|
88
66
|
|
|
89
67
|
signal.signal(signal.SIGINT, sigint_handler)
|
|
90
|
-
|
|
68
|
+
|
|
91
69
|
with concurrent.futures.ThreadPoolExecutor(
|
|
92
|
-
max_workers=len(self.input_dto.config.
|
|
70
|
+
max_workers=len(self.input_dto.config.test_instances),
|
|
93
71
|
) as instance_pool, concurrent.futures.ThreadPoolExecutor(
|
|
94
72
|
max_workers=len(self.input_dto.views)
|
|
95
73
|
) as view_runner, concurrent.futures.ThreadPoolExecutor(
|
|
96
|
-
max_workers=len(self.input_dto.config.
|
|
74
|
+
max_workers=len(self.input_dto.config.test_instances),
|
|
97
75
|
) as view_shutdowner:
|
|
98
76
|
|
|
99
77
|
# Start all the views
|
|
@@ -151,14 +129,41 @@ class DetectionTestingManager(BaseModel):
|
|
|
151
129
|
return self.output_dto
|
|
152
130
|
|
|
153
131
|
def create_DetectionTestingInfrastructureObjects(self):
|
|
154
|
-
|
|
132
|
+
#Make sure that, if we need to, we pull the appropriate container
|
|
133
|
+
for infrastructure in self.input_dto.config.test_instances:
|
|
134
|
+
if (isinstance(self.input_dto.config, test) and isinstance(infrastructure, Container)):
|
|
135
|
+
try:
|
|
136
|
+
client = docker.from_env()
|
|
137
|
+
except Exception as e:
|
|
138
|
+
raise Exception("Unable to connect to docker. Are you sure that docker is running on this host?")
|
|
139
|
+
try:
|
|
140
|
+
|
|
141
|
+
parts = self.input_dto.config.container_settings.full_image_path.split(':')
|
|
142
|
+
if len(parts) != 2:
|
|
143
|
+
raise Exception(f"Expected to find a name:tag in {self.input_dto.config.container_settings.full_image_path}, "
|
|
144
|
+
f"but instead found {parts}. Note that this path MUST include the tag, which is separated by ':'")
|
|
145
|
+
|
|
146
|
+
print(
|
|
147
|
+
f"Getting the latest version of the container image [{self.input_dto.config.container_settings.full_image_path}]...",
|
|
148
|
+
end="",
|
|
149
|
+
flush=True,
|
|
150
|
+
)
|
|
151
|
+
client.images.pull(parts[0], tag=parts[1], platform="linux/amd64")
|
|
152
|
+
print("done!")
|
|
153
|
+
break
|
|
154
|
+
except Exception as e:
|
|
155
|
+
raise Exception(f"Failed to pull docker container image [{self.input_dto.config.container_settings.full_image_path}]: {str(e)}")
|
|
155
156
|
|
|
156
|
-
|
|
157
|
+
already_staged_container_files = False
|
|
158
|
+
for infrastructure in self.input_dto.config.test_instances:
|
|
157
159
|
|
|
158
|
-
if (
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
160
|
+
if (isinstance(self.input_dto.config, test) and isinstance(infrastructure, Container)):
|
|
161
|
+
# Stage the files in the apps dir so that they can be passed directly to
|
|
162
|
+
# subsequent containers. Do this here, instead of inside each container, to
|
|
163
|
+
# avoid duplicate downloads/moves/copies
|
|
164
|
+
if not already_staged_container_files:
|
|
165
|
+
self.input_dto.config.getContainerEnvironmentString(stage_file=True)
|
|
166
|
+
already_staged_container_files = True
|
|
162
167
|
|
|
163
168
|
self.detectionTestingInfrastructureObjects.append(
|
|
164
169
|
DetectionTestingInfrastructureContainer(
|
|
@@ -166,11 +171,7 @@ class DetectionTestingManager(BaseModel):
|
|
|
166
171
|
)
|
|
167
172
|
)
|
|
168
173
|
|
|
169
|
-
elif (
|
|
170
|
-
self.input_dto.config.infrastructure_config.infrastructure_type
|
|
171
|
-
== DetectionTestingTargetInfrastructure.server
|
|
172
|
-
):
|
|
173
|
-
|
|
174
|
+
elif (isinstance(self.input_dto.config, test_servers) and isinstance(infrastructure, Infrastructure)):
|
|
174
175
|
self.detectionTestingInfrastructureObjects.append(
|
|
175
176
|
DetectionTestingInfrastructureServer(
|
|
176
177
|
global_config=self.input_dto.config, infrastructure=infrastructure, sync_obj=self.output_dto
|
|
@@ -179,7 +180,5 @@ class DetectionTestingManager(BaseModel):
|
|
|
179
180
|
|
|
180
181
|
else:
|
|
181
182
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
)
|
|
185
|
-
sys.exit(1)
|
|
183
|
+
raise Exception(f"Unsupported target infrastructure '{infrastructure}' and config type {self.input_dto.config}")
|
|
184
|
+
|
|
@@ -1,258 +1,176 @@
|
|
|
1
|
-
import csv
|
|
2
|
-
import glob
|
|
3
1
|
import logging
|
|
4
2
|
import os
|
|
5
3
|
import pathlib
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
from typing import
|
|
9
|
-
from
|
|
10
|
-
import
|
|
11
|
-
|
|
12
|
-
import
|
|
13
|
-
|
|
4
|
+
import pygit2
|
|
5
|
+
from pygit2.enums import DeltaStatus
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
from pydantic import BaseModel, FilePath
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from contentctl.input.director import DirectorOutputDto
|
|
11
|
+
|
|
14
12
|
|
|
15
|
-
from contentctl.objects.detection import Detection
|
|
16
|
-
from contentctl.objects.story import Story
|
|
17
|
-
from contentctl.objects.baseline import Baseline
|
|
18
|
-
from contentctl.objects.investigation import Investigation
|
|
19
|
-
from contentctl.objects.playbook import Playbook
|
|
20
13
|
from contentctl.objects.macro import Macro
|
|
21
14
|
from contentctl.objects.lookup import Lookup
|
|
22
|
-
from contentctl.objects.
|
|
23
|
-
|
|
24
|
-
from contentctl.objects.
|
|
25
|
-
import random
|
|
26
|
-
import pathlib
|
|
27
|
-
from contentctl.helper.utils import Utils
|
|
28
|
-
|
|
29
|
-
from contentctl.objects.test_config import TestConfig
|
|
30
|
-
from contentctl.actions.generate import DirectorOutputDto
|
|
15
|
+
from contentctl.objects.detection import Detection
|
|
16
|
+
from contentctl.objects.security_content_object import SecurityContentObject
|
|
17
|
+
from contentctl.objects.config import test_common, All, Changes, Selected
|
|
31
18
|
|
|
32
19
|
# Logger
|
|
33
20
|
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
|
|
34
21
|
LOGGER = logging.getLogger(__name__)
|
|
35
22
|
|
|
36
23
|
|
|
37
|
-
SSA_PREFIX = "ssa___"
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
class GitService:
|
|
41
|
-
def get_all_content(self, director: DirectorOutputDto) -> DirectorOutputDto:
|
|
42
|
-
# get a new director that will be used for testing.
|
|
43
|
-
return DirectorOutputDto(
|
|
44
|
-
self.get_detections(director),
|
|
45
|
-
self.get_stories(director),
|
|
46
|
-
self.get_baselines(director),
|
|
47
|
-
self.get_investigations(director),
|
|
48
|
-
self.get_playbooks(director),
|
|
49
|
-
self.get_macros(director),
|
|
50
|
-
self.get_lookups(director),
|
|
51
|
-
[],
|
|
52
|
-
[]
|
|
53
|
-
)
|
|
54
24
|
|
|
55
|
-
|
|
56
|
-
stories: list[Story] = []
|
|
57
|
-
return stories
|
|
25
|
+
from contentctl.input.director import DirectorOutputDto
|
|
58
26
|
|
|
59
|
-
def get_baselines(self, director: DirectorOutputDto) -> list[Baseline]:
|
|
60
|
-
baselines: list[Baseline] = []
|
|
61
|
-
return baselines
|
|
62
27
|
|
|
63
|
-
def get_investigations(self, director: DirectorOutputDto) -> list[Investigation]:
|
|
64
|
-
investigations: list[Investigation] = []
|
|
65
|
-
return investigations
|
|
66
28
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
29
|
+
class GitService(BaseModel):
|
|
30
|
+
director: DirectorOutputDto
|
|
31
|
+
config: test_common
|
|
32
|
+
gitHash: Optional[str] = None
|
|
33
|
+
|
|
34
|
+
def getHash(self)->str:
|
|
35
|
+
if self.gitHash is None:
|
|
36
|
+
raise Exception("Cannot get hash of repo, it was not set")
|
|
37
|
+
return self.gitHash
|
|
70
38
|
|
|
71
|
-
def get_macros(self, director: DirectorOutputDto) -> list[Macro]:
|
|
72
|
-
macros: list[Macro] = []
|
|
73
|
-
return macros
|
|
74
39
|
|
|
75
|
-
def
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
#print()
|
|
83
|
-
return [detection for detection in detections if DetectionStatus(detection.status) in statuses_to_test]
|
|
84
|
-
|
|
85
|
-
# TODO (cmcginley): consider listing Correlation type detections as skips rather than excluding
|
|
86
|
-
# them from results altogether?
|
|
87
|
-
def filter_detections_by_type(self, detections: list[Detection],
|
|
88
|
-
types_to_test: set[AnalyticsType] = {AnalyticsType.Anomaly, AnalyticsType.TTP, AnalyticsType.Hunting})->list[Detection]:
|
|
89
|
-
#print("\n".join(sorted([f"{detection.file_path[92:]} - {detection.type}" for detection in detections if AnalyticsType(detection.type) not in types_to_test])))
|
|
90
|
-
#print()
|
|
91
|
-
return [detection for detection in detections if AnalyticsType(detection.type) in types_to_test]
|
|
92
|
-
def get_detections(self, director: DirectorOutputDto) -> list[Detection]:
|
|
93
|
-
if self.config.mode == DetectionTestingMode.selected:
|
|
94
|
-
detections = self.get_detections_selected(director)
|
|
95
|
-
elif self.config.mode == DetectionTestingMode.all:
|
|
96
|
-
detections = self.get_detections_all(director)
|
|
97
|
-
elif self.config.mode == DetectionTestingMode.changes:
|
|
98
|
-
detections = self.get_detections_changed(director)
|
|
40
|
+
def getContent(self)->List[Detection]:
|
|
41
|
+
if isinstance(self.config.mode, Selected):
|
|
42
|
+
return self.getSelected(self.config.mode.files)
|
|
43
|
+
elif isinstance(self.config.mode, Changes):
|
|
44
|
+
return self.getChanges(self.config.mode.target_branch)
|
|
45
|
+
if isinstance(self.config.mode, All):
|
|
46
|
+
return self.getAll()
|
|
99
47
|
else:
|
|
100
|
-
raise (
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
48
|
+
raise Exception(f"Could not get content to test. Unsupported test mode '{self.config.mode}'")
|
|
49
|
+
def getAll(self)->List[Detection]:
|
|
50
|
+
return self.director.detections
|
|
51
|
+
|
|
52
|
+
def getChanges(self, target_branch:str)->List[Detection]:
|
|
53
|
+
repo = pygit2.Repository(path=str(self.config.path))
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
target_tree = repo.revparse_single(target_branch).tree
|
|
57
|
+
self.gitHash = target_tree.id
|
|
58
|
+
diffs = repo.index.diff_to_tree(target_tree)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
raise Exception(f"Error parsing diff target_branch '{target_branch}'. Are you certain that it exists?")
|
|
105
61
|
|
|
62
|
+
#Get the uncommitted changes in the current directory
|
|
63
|
+
diffs2 = repo.index.diff_to_workdir()
|
|
106
64
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
65
|
+
#Combine the uncommitted changes with the committed changes
|
|
66
|
+
all_diffs = list(diffs) + list(diffs2)
|
|
67
|
+
|
|
68
|
+
#Make a filename to content map
|
|
69
|
+
filepath_to_content_map = { obj.file_path:obj for (_,obj) in self.director.name_to_content_map.items()}
|
|
70
|
+
updated_detections:List[Detection] = []
|
|
71
|
+
updated_macros:List[Macro] = []
|
|
72
|
+
updated_lookups:List[Lookup] =[]
|
|
73
|
+
|
|
74
|
+
for diff in all_diffs:
|
|
75
|
+
if type(diff) == pygit2.Patch:
|
|
76
|
+
if diff.delta.status in (DeltaStatus.ADDED, DeltaStatus.MODIFIED, DeltaStatus.RENAMED):
|
|
77
|
+
#print(f"{DeltaStatus(diff.delta.status).name:<8}:{diff.delta.new_file.raw_path}")
|
|
78
|
+
decoded_path = pathlib.Path(diff.delta.new_file.raw_path.decode('utf-8'))
|
|
79
|
+
if 'app_template/' in str(decoded_path) or 'ssa_detections' in str(decoded_path) or str(self.config.getBuildDir()) in str(decoded_path):
|
|
80
|
+
#Ignore anything that is embedded in the app template.
|
|
81
|
+
#Also ignore ssa detections
|
|
82
|
+
pass
|
|
83
|
+
elif 'detections/' in str(decoded_path) and decoded_path.suffix == ".yml":
|
|
84
|
+
detectionObject = filepath_to_content_map.get(decoded_path, None)
|
|
85
|
+
if isinstance(detectionObject, Detection):
|
|
86
|
+
updated_detections.append(detectionObject)
|
|
87
|
+
else:
|
|
88
|
+
raise Exception(f"Error getting detection object for file {str(decoded_path)}")
|
|
89
|
+
|
|
90
|
+
elif 'macros/' in str(decoded_path) and decoded_path.suffix == ".yml":
|
|
91
|
+
macroObject = filepath_to_content_map.get(decoded_path, None)
|
|
92
|
+
if isinstance(macroObject, Macro):
|
|
93
|
+
updated_macros.append(macroObject)
|
|
94
|
+
else:
|
|
95
|
+
raise Exception(f"Error getting macro object for file {str(decoded_path)}")
|
|
96
|
+
|
|
97
|
+
elif 'lookups/' in str(decoded_path):
|
|
98
|
+
# We need to convert this to a yml. This means we will catch
|
|
99
|
+
# both changes to a csv AND changes to the YML that uses it
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if decoded_path.suffix == ".yml":
|
|
103
|
+
updatedLookup = filepath_to_content_map.get(decoded_path, None)
|
|
104
|
+
if not isinstance(updatedLookup,Lookup):
|
|
105
|
+
raise Exception(f"Expected {decoded_path} to be type {type(Lookup)}, but instead if was {(type(lookupObject))}")
|
|
106
|
+
updated_lookups.append(updatedLookup)
|
|
107
|
+
|
|
108
|
+
elif decoded_path.suffix == ".csv":
|
|
109
|
+
# If the CSV was updated, we want to make sure that we
|
|
110
|
+
# add the correct corresponding Lookup object.
|
|
111
|
+
#Filter to find the Lookup Object the references this CSV
|
|
112
|
+
matched = list(filter(lambda x: x.filename is not None and x.filename == decoded_path, self.director.lookups))
|
|
113
|
+
if len(matched) == 0:
|
|
114
|
+
raise Exception(f"Failed to find any lookups that reference the modified CSV file '{decoded_path}'")
|
|
115
|
+
elif len(matched) > 1:
|
|
116
|
+
raise Exception(f"More than 1 Lookup reference the modified CSV file '{decoded_path}': {[l.file_path for l in matched ]}")
|
|
117
|
+
else:
|
|
118
|
+
updatedLookup = matched[0]
|
|
119
|
+
else:
|
|
120
|
+
raise Exception(f"Error getting lookup object for file {str(decoded_path)}")
|
|
121
|
+
|
|
122
|
+
if updatedLookup not in updated_lookups:
|
|
123
|
+
# It is possible that both th CSV and YML have been modified for the same lookup,
|
|
124
|
+
# and we do not want to add it twice.
|
|
125
|
+
updated_lookups.append(updatedLookup)
|
|
126
|
+
|
|
127
|
+
else:
|
|
128
|
+
pass
|
|
129
|
+
#print(f"Ignore changes to file {decoded_path} since it is not a detection, macro, or lookup.")
|
|
130
|
+
|
|
131
|
+
# else:
|
|
132
|
+
# print(f"{diff.delta.new_file.raw_path}:{DeltaStatus(diff.delta.status).name} (IGNORED)")
|
|
133
|
+
# pass
|
|
129
134
|
else:
|
|
130
|
-
raise (
|
|
131
|
-
Exception(
|
|
132
|
-
f"Error: multiple detection files found when attemping to resolve [{str(requested)}]"
|
|
133
|
-
)
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
if len(missing_detections) > 0:
|
|
137
|
-
missing_detections_str = "\n\t - ".join(
|
|
138
|
-
[str(path.absolute()) for path in missing_detections]
|
|
139
|
-
)
|
|
140
|
-
print(director.detections)
|
|
141
|
-
raise (
|
|
142
|
-
Exception(
|
|
143
|
-
f"Failed to find the following detection file(s) for testing:\n\t - {missing_detections_str}"
|
|
144
|
-
)
|
|
145
|
-
)
|
|
135
|
+
raise Exception(f"Unrecognized type {type(diff)}")
|
|
146
136
|
|
|
147
|
-
return detections_to_test
|
|
148
137
|
|
|
149
|
-
|
|
150
|
-
#
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
def get_detections_changed(self, director: DirectorOutputDto) -> list[Detection]:
|
|
154
|
-
if self.repo is None:
|
|
155
|
-
raise (
|
|
156
|
-
Exception(
|
|
157
|
-
f"Error: self.repo must be initialized before getting changed detections."
|
|
158
|
-
)
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
target_branch_repo_object = self.repo.commit(f"origin/{self.config.version_control_config.target_branch}")
|
|
162
|
-
test_branch_repo_object = self.repo.commit(self.config.version_control_config.test_branch)
|
|
163
|
-
differences = target_branch_repo_object.diff(test_branch_repo_object)
|
|
164
|
-
|
|
165
|
-
new_content = []
|
|
166
|
-
modified_content = []
|
|
167
|
-
deleted_content = []
|
|
168
|
-
renamed_content = []
|
|
169
|
-
|
|
170
|
-
for content in differences.iter_change_type("M"):
|
|
171
|
-
modified_content.append(content.b_path)
|
|
172
|
-
for content in differences.iter_change_type("A"):
|
|
173
|
-
new_content.append(content.b_path)
|
|
174
|
-
for content in differences.iter_change_type("D"):
|
|
175
|
-
deleted_content.append(content.b_path)
|
|
176
|
-
for content in differences.iter_change_type("R"):
|
|
177
|
-
renamed_content.append(content.b_path)
|
|
178
|
-
|
|
179
|
-
#Changes to detections, macros, and lookups should trigger a re-test for anything which uses them
|
|
180
|
-
changed_lookups_list = list(filter(lambda x: x.startswith("lookups"), new_content+modified_content))
|
|
181
|
-
changed_lookups = set()
|
|
182
|
-
|
|
183
|
-
#We must account for changes to the lookup yml AND for the underlying csv
|
|
184
|
-
for lookup in changed_lookups_list:
|
|
185
|
-
if lookup.endswith(".csv"):
|
|
186
|
-
lookup = lookup.replace(".csv", ".yml")
|
|
187
|
-
changed_lookups.add(lookup)
|
|
188
|
-
|
|
189
|
-
# At some point we should account for macros which contain other macros...
|
|
190
|
-
changed_macros = set(filter(lambda x: x.startswith("macros"), new_content+modified_content))
|
|
191
|
-
changed_macros_and_lookups = set([str(pathlib.Path(filename).absolute()) for filename in changed_lookups.union(changed_macros)])
|
|
192
|
-
|
|
193
|
-
changed_detections = set(filter(lambda x: x.startswith("detections"), new_content+modified_content+renamed_content))
|
|
194
|
-
|
|
195
|
-
#Check and see if content that has been modified uses any of the changed macros or lookups
|
|
196
|
-
for detection in director.detections:
|
|
197
|
-
deps = set([content.file_path for content in detection.get_content_dependencies()])
|
|
198
|
-
if not deps.isdisjoint(changed_macros_and_lookups):
|
|
199
|
-
changed_detections.add(detection.file_path)
|
|
200
|
-
|
|
201
|
-
changed_detections_string = '\n - '.join(changed_detections)
|
|
202
|
-
#print(f"The following [{len(changed_detections)}] detections, or their dependencies (macros/lookups), have changed:\n - {changed_detections_string}")
|
|
203
|
-
return Detection.get_detections_from_filenames(changed_detections, director.detections)
|
|
204
|
-
|
|
205
|
-
def __init__(self, config: TestConfig):
|
|
138
|
+
# If a detection has at least one dependency on changed content,
|
|
139
|
+
# then we must test it again
|
|
140
|
+
changed_macros_and_lookups = updated_macros + updated_lookups
|
|
206
141
|
|
|
207
|
-
self.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
142
|
+
for detection in self.director.detections:
|
|
143
|
+
if detection in updated_detections:
|
|
144
|
+
# we are already planning to test it, don't need
|
|
145
|
+
# to add it again
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
for obj in changed_macros_and_lookups:
|
|
149
|
+
if obj in detection.get_content_dependencies():
|
|
150
|
+
updated_detections.append(detection)
|
|
151
|
+
break
|
|
152
|
+
|
|
153
|
+
#Print out the names of all modified/new content
|
|
154
|
+
modifiedAndNewContentString = "\n - ".join(sorted([d.name for d in updated_detections]))
|
|
155
|
+
|
|
156
|
+
print(f"[{len(updated_detections)}] Pieces of modifed and new content to test:\n - {modifiedAndNewContentString}")
|
|
157
|
+
return updated_detections
|
|
158
|
+
|
|
159
|
+
def getSelected(self, detectionFilenames:List[FilePath])->List[Detection]:
|
|
160
|
+
filepath_to_content_map:dict[FilePath, SecurityContentObject] = { obj.file_path:obj for (_,obj) in self.director.name_to_content_map.items() if obj.file_path is not None}
|
|
161
|
+
errors = []
|
|
162
|
+
detections:List[Detection] = []
|
|
163
|
+
for name in detectionFilenames:
|
|
164
|
+
obj = filepath_to_content_map.get(name,None)
|
|
165
|
+
if obj == None:
|
|
166
|
+
errors.append(f"There is no detection file or security_content_object at '{name}'")
|
|
167
|
+
elif not isinstance(obj, Detection):
|
|
168
|
+
errors.append(f"The security_content_object at '{name}' is of type '{type(obj).__name__}', NOT '{Detection.__name__}'")
|
|
228
169
|
else:
|
|
229
|
-
|
|
230
|
-
missing_files = [
|
|
231
|
-
detection
|
|
232
|
-
for detection in config.detections_list
|
|
233
|
-
if not pathlib.Path(detection).is_file()
|
|
234
|
-
]
|
|
235
|
-
if len(missing_files) > 0:
|
|
236
|
-
missing_string = "\n\t - ".join(missing_files)
|
|
237
|
-
raise (
|
|
238
|
-
Exception(
|
|
239
|
-
f"Error: The following detection(s) test do not exist:\n\t - {missing_files}"
|
|
240
|
-
)
|
|
241
|
-
)
|
|
242
|
-
else:
|
|
243
|
-
self.requested_detections = [
|
|
244
|
-
pathlib.Path(detection_file_name)
|
|
245
|
-
for detection_file_name in config.detections_list
|
|
246
|
-
]
|
|
247
|
-
|
|
248
|
-
else:
|
|
249
|
-
raise Exception(f"Unsupported detection testing mode [{config.mode}]. "\
|
|
250
|
-
"Supported detection testing modes are [{DetectionTestingMode._member_names_}]")
|
|
251
|
-
return
|
|
252
|
-
|
|
170
|
+
detections.append(obj)
|
|
253
171
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
return
|
|
172
|
+
if len(errors) > 0:
|
|
173
|
+
errorsString = "\n - ".join(errors)
|
|
174
|
+
raise Exception(f"There following errors were encountered while getting selected detections to test:\n - {errorsString}")
|
|
175
|
+
return detections
|
|
258
176
|
|