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
@@ -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 contentctl.objects.test_config import TestConfig
2
- from contentctl.actions.detection_testing.infrastructures.DetectionTestingInfrastructure import (
3
- DetectionTestingInfrastructure,
4
- )
5
- from contentctl.actions.detection_testing.infrastructures.DetectionTestingInfrastructureContainer import (
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: TestConfig
54
- testContent: DirectorOutputDto
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.testContent.detections
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.infrastructure_config.infrastructures),
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.infrastructure_config.infrastructures),
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
- import sys
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
- for infrastructure in self.input_dto.config.infrastructure_config.infrastructures:
157
+ already_staged_container_files = False
158
+ for infrastructure in self.input_dto.config.test_instances:
157
159
 
158
- if (
159
- self.input_dto.config.infrastructure_config.infrastructure_type
160
- == DetectionTestingTargetInfrastructure.container
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
- print(
183
- f"Unsupported target infrastructure '{self.input_dto.config.infrastructure_config.infrastructure_type}'"
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 subprocess
7
- import sys
8
- from typing import Union, Tuple
9
- from docker import types
10
- import datetime
11
- import git
12
- import yaml
13
- from git.objects import base
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.unit_test import UnitTest
23
-
24
- from contentctl.objects.enums import DetectionTestingMode, DetectionStatus, AnalyticsType
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
- def get_stories(self, director: DirectorOutputDto) -> list[Story]:
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
- def get_playbooks(self, director: DirectorOutputDto) -> list[Playbook]:
68
- playbooks: list[Playbook] = []
69
- return playbooks
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 get_lookups(self, director: DirectorOutputDto) -> list[Lookup]:
76
- lookups: list[Lookup] = []
77
- return lookups
78
-
79
- def filter_detections_by_status(self, detections: list[Detection],
80
- statuses_to_test: set[DetectionStatus] = {DetectionStatus.production})->list[Detection]:
81
- #print("\n".join(sorted([f"{detection.file_path[92:]} - {detection.status}" for detection in detections if DetectionStatus(detection.status) not in statuses_to_test])))
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
- Exception(
102
- f"Error: Unsupported detection testing mode in GitService: {self.config.mode}"
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
- detections = self.filter_detections_by_status(detections)
108
-
109
- detections = self.filter_detections_by_type(detections)
110
- return detections
111
-
112
- def get_detections_selected(self, director: DirectorOutputDto) -> list[Detection]:
113
- detections_to_test: list[Detection] = []
114
- requested_set = set(self.requested_detections)
115
- missing_detections: set[pathlib.Path] = set()
116
-
117
- for requested in requested_set:
118
- matching = list(
119
- filter(
120
- lambda detection: pathlib.Path(detection.file_path).resolve()
121
- == requested.resolve(),
122
- director.detections,
123
- )
124
- )
125
- if len(matching) == 1:
126
- detections_to_test.append(matching.pop())
127
- elif len(matching) == 0:
128
- missing_detections.add(requested)
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
- def get_detections_all(self, director: DirectorOutputDto) -> list[Detection]:
150
- # Assume we don't need to remove anything, like deprecated or experimental from this
151
- return director.detections
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.requested_detections: list[pathlib.Path] = []
208
- self.config = config
209
- if config.version_control_config is not None:
210
- self.repo = git.Repo(config.version_control_config.repo_path)
211
- else:
212
- self.repo = None
213
-
214
-
215
- if config.mode == DetectionTestingMode.changes:
216
- if self.repo is None:
217
- raise Exception("You are using detection mode 'changes', but the app does not have a version_control_config in contentctl_test.yml.")
218
- return
219
- elif config.mode == DetectionTestingMode.all:
220
- return
221
- elif config.mode == DetectionTestingMode.selected:
222
- if config.detections_list is None or len(config.detections_list) < 1:
223
- raise (
224
- Exception(
225
- f"Error: detection mode [{config.mode}] REQUIRES that [{config.detections_list}] contains 1 or more detections, but the value is [{config.detections_list}]"
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
- # Ensure that all of the detections exist
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
- def clone_project(self, url, project, branch):
255
- LOGGER.info(f"Clone Security Content Project")
256
- repo_obj = git.Repo.clone_from(url, project, branch=branch)
257
- return repo_obj
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