contentctl 3.6.0__py3-none-any.whl → 4.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. contentctl/actions/build.py +89 -0
  2. contentctl/actions/detection_testing/DetectionTestingManager.py +48 -49
  3. contentctl/actions/detection_testing/GitService.py +148 -230
  4. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +14 -24
  5. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +43 -17
  6. contentctl/actions/detection_testing/views/DetectionTestingView.py +3 -2
  7. contentctl/actions/detection_testing/views/DetectionTestingViewFile.py +0 -8
  8. contentctl/actions/doc_gen.py +1 -1
  9. contentctl/actions/initialize.py +28 -65
  10. contentctl/actions/inspect.py +260 -0
  11. contentctl/actions/new_content.py +106 -13
  12. contentctl/actions/release_notes.py +168 -144
  13. contentctl/actions/reporting.py +24 -13
  14. contentctl/actions/test.py +39 -20
  15. contentctl/actions/validate.py +25 -48
  16. contentctl/contentctl.py +196 -754
  17. contentctl/enrichments/attack_enrichment.py +69 -19
  18. contentctl/enrichments/cve_enrichment.py +28 -13
  19. contentctl/helper/link_validator.py +24 -26
  20. contentctl/helper/utils.py +7 -3
  21. contentctl/input/director.py +139 -201
  22. contentctl/input/new_content_questions.py +63 -61
  23. contentctl/input/sigma_converter.py +1 -2
  24. contentctl/input/ssa_detection_builder.py +16 -7
  25. contentctl/input/yml_reader.py +4 -3
  26. contentctl/objects/abstract_security_content_objects/detection_abstract.py +487 -154
  27. contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +155 -51
  28. contentctl/objects/alert_action.py +40 -0
  29. contentctl/objects/atomic.py +212 -0
  30. contentctl/objects/baseline.py +44 -43
  31. contentctl/objects/baseline_tags.py +69 -20
  32. contentctl/objects/config.py +857 -125
  33. contentctl/objects/constants.py +0 -1
  34. contentctl/objects/correlation_search.py +1 -1
  35. contentctl/objects/data_source.py +2 -4
  36. contentctl/objects/deployment.py +61 -21
  37. contentctl/objects/deployment_email.py +2 -2
  38. contentctl/objects/deployment_notable.py +4 -4
  39. contentctl/objects/deployment_phantom.py +2 -2
  40. contentctl/objects/deployment_rba.py +3 -4
  41. contentctl/objects/deployment_scheduling.py +2 -3
  42. contentctl/objects/deployment_slack.py +2 -2
  43. contentctl/objects/detection.py +1 -5
  44. contentctl/objects/detection_tags.py +210 -119
  45. contentctl/objects/enums.py +312 -24
  46. contentctl/objects/integration_test.py +1 -1
  47. contentctl/objects/integration_test_result.py +0 -2
  48. contentctl/objects/investigation.py +62 -53
  49. contentctl/objects/investigation_tags.py +30 -6
  50. contentctl/objects/lookup.py +80 -31
  51. contentctl/objects/macro.py +29 -45
  52. contentctl/objects/mitre_attack_enrichment.py +29 -5
  53. contentctl/objects/observable.py +3 -7
  54. contentctl/objects/playbook.py +60 -30
  55. contentctl/objects/playbook_tags.py +45 -8
  56. contentctl/objects/security_content_object.py +1 -5
  57. contentctl/objects/ssa_detection.py +8 -4
  58. contentctl/objects/ssa_detection_tags.py +19 -26
  59. contentctl/objects/story.py +142 -44
  60. contentctl/objects/story_tags.py +46 -33
  61. contentctl/objects/unit_test.py +7 -2
  62. contentctl/objects/unit_test_attack_data.py +10 -19
  63. contentctl/objects/unit_test_baseline.py +1 -1
  64. contentctl/objects/unit_test_old.py +4 -3
  65. contentctl/objects/unit_test_result.py +5 -3
  66. contentctl/objects/unit_test_ssa.py +31 -0
  67. contentctl/output/api_json_output.py +202 -130
  68. contentctl/output/attack_nav_output.py +20 -9
  69. contentctl/output/attack_nav_writer.py +3 -3
  70. contentctl/output/ba_yml_output.py +3 -3
  71. contentctl/output/conf_output.py +125 -391
  72. contentctl/output/conf_writer.py +169 -31
  73. contentctl/output/jinja_writer.py +2 -2
  74. contentctl/output/json_writer.py +17 -5
  75. contentctl/output/new_content_yml_output.py +8 -7
  76. contentctl/output/svg_output.py +17 -27
  77. contentctl/output/templates/analyticstories_detections.j2 +8 -4
  78. contentctl/output/templates/analyticstories_investigations.j2 +1 -1
  79. contentctl/output/templates/analyticstories_stories.j2 +6 -6
  80. contentctl/output/templates/app.conf.j2 +2 -2
  81. contentctl/output/templates/app.manifest.j2 +2 -2
  82. contentctl/output/templates/detection_coverage.j2 +6 -8
  83. contentctl/output/templates/doc_detection_page.j2 +2 -2
  84. contentctl/output/templates/doc_detections.j2 +2 -2
  85. contentctl/output/templates/doc_stories.j2 +1 -1
  86. contentctl/output/templates/es_investigations_investigations.j2 +1 -1
  87. contentctl/output/templates/es_investigations_stories.j2 +1 -1
  88. contentctl/output/templates/header.j2 +2 -1
  89. contentctl/output/templates/macros.j2 +6 -10
  90. contentctl/output/templates/savedsearches_baselines.j2 +5 -5
  91. contentctl/output/templates/savedsearches_detections.j2 +36 -33
  92. contentctl/output/templates/savedsearches_investigations.j2 +4 -4
  93. contentctl/output/templates/transforms.j2 +4 -4
  94. contentctl/output/yml_writer.py +2 -2
  95. contentctl/templates/app_template/README.md +7 -0
  96. contentctl/{output/templates/splunk_app → templates/app_template}/default/data/ui/nav/default.xml +1 -0
  97. contentctl/templates/app_template/lookups/mitre_enrichment.csv +638 -0
  98. contentctl/templates/deployments/{00_default_anomaly.yml → escu_default_configuration_anomaly.yml} +1 -2
  99. contentctl/templates/deployments/{00_default_baseline.yml → escu_default_configuration_baseline.yml} +1 -2
  100. contentctl/templates/deployments/{00_default_correlation.yml → escu_default_configuration_correlation.yml} +2 -2
  101. contentctl/templates/deployments/{00_default_hunting.yml → escu_default_configuration_hunting.yml} +2 -2
  102. contentctl/templates/deployments/{00_default_ttp.yml → escu_default_configuration_ttp.yml} +1 -2
  103. contentctl/templates/detections/anomalous_usage_of_7zip.yml +0 -1
  104. contentctl/templates/stories/cobalt_strike.yml +0 -1
  105. {contentctl-3.6.0.dist-info → contentctl-4.0.2.dist-info}/METADATA +36 -15
  106. contentctl-4.0.2.dist-info/RECORD +168 -0
  107. contentctl/actions/detection_testing/DataManipulation.py +0 -149
  108. contentctl/actions/generate.py +0 -91
  109. contentctl/helper/config_handler.py +0 -75
  110. contentctl/input/baseline_builder.py +0 -66
  111. contentctl/input/basic_builder.py +0 -58
  112. contentctl/input/detection_builder.py +0 -370
  113. contentctl/input/investigation_builder.py +0 -42
  114. contentctl/input/new_content_generator.py +0 -95
  115. contentctl/input/playbook_builder.py +0 -68
  116. contentctl/input/story_builder.py +0 -106
  117. contentctl/objects/app.py +0 -214
  118. contentctl/objects/repo_config.py +0 -163
  119. contentctl/objects/test_config.py +0 -630
  120. contentctl/output/templates/macros_detections.j2 +0 -7
  121. contentctl/output/templates/splunk_app/README.md +0 -7
  122. contentctl-3.6.0.dist-info/RECORD +0 -176
  123. /contentctl/{output/templates/splunk_app → templates/app_template}/README/essoc_story_detail.txt +0 -0
  124. /contentctl/{output/templates/splunk_app → templates/app_template}/README/essoc_summary.txt +0 -0
  125. /contentctl/{output/templates/splunk_app → templates/app_template}/README/essoc_usage_dashboard.txt +0 -0
  126. /contentctl/{output/templates/splunk_app → templates/app_template}/default/analytic_stories.conf +0 -0
  127. /contentctl/{output/templates/splunk_app → templates/app_template}/default/app.conf +0 -0
  128. /contentctl/{output/templates/splunk_app → templates/app_template}/default/commands.conf +0 -0
  129. /contentctl/{output/templates/splunk_app → templates/app_template}/default/content-version.conf +0 -0
  130. /contentctl/{output/templates/splunk_app → templates/app_template}/default/data/ui/views/escu_summary.xml +0 -0
  131. /contentctl/{output/templates/splunk_app → templates/app_template}/default/data/ui/views/feedback.xml +0 -0
  132. /contentctl/{output/templates/splunk_app → templates/app_template}/default/distsearch.conf +0 -0
  133. /contentctl/{output/templates/splunk_app → templates/app_template}/default/usage_searches.conf +0 -0
  134. /contentctl/{output/templates/splunk_app → templates/app_template}/default/use_case_library.conf +0 -0
  135. /contentctl/{output/templates/splunk_app → templates/app_template}/metadata/default.meta +0 -0
  136. /contentctl/{output/templates/splunk_app → templates/app_template}/static/appIcon.png +0 -0
  137. /contentctl/{output/templates/splunk_app → templates/app_template}/static/appIconAlt.png +0 -0
  138. /contentctl/{output/templates/splunk_app → templates/app_template}/static/appIconAlt_2x.png +0 -0
  139. /contentctl/{output/templates/splunk_app → templates/app_template}/static/appIcon_2x.png +0 -0
  140. {contentctl-3.6.0.dist-info → contentctl-4.0.2.dist-info}/LICENSE.md +0 -0
  141. {contentctl-3.6.0.dist-info → contentctl-4.0.2.dist-info}/WHEEL +0 -0
  142. {contentctl-3.6.0.dist-info → contentctl-4.0.2.dist-info}/entry_points.txt +0 -0
@@ -1,110 +1,107 @@
1
- from pydantic import BaseModel, validator, ValidationError, Field, Extra
1
+ from __future__ import annotations
2
+ from pydantic import (
3
+ BaseModel, Field, field_validator,
4
+ field_serializer, ConfigDict, DirectoryPath,
5
+ PositiveInt, FilePath, HttpUrl, AnyUrl, model_validator,
6
+ ValidationInfo
7
+ )
8
+ from contentctl.output.yml_writer import YmlWriter
9
+ from os import environ
10
+ from datetime import datetime, UTC
11
+ from typing import Optional,Any,Annotated,List,Union, Self
2
12
  import semantic_version
3
- from datetime import datetime
4
- from typing import Union
5
- from contentctl.objects.test_config import TestConfig
6
-
7
- import string
8
13
  import random
9
- PASSWORD = ''.join([random.choice(string.ascii_letters + string.digits) for i in range(16)])
10
-
11
- class ConfigGlobal(BaseModel):
12
- log_path: str
13
- log_level: str
14
-
15
-
16
- class ConfigScheduling(BaseModel):
17
- cron_schedule: str
18
- earliest_time: str
19
- latest_time: str
20
- schedule_window: str
21
-
22
-
23
- class ConfigNotable(BaseModel):
24
- rule_description: str
25
- rule_title: str
26
- nes_fields: list
27
-
28
-
29
- class ConfigEmail(BaseModel):
30
- subject: str
31
- to: str
32
- message: str
33
-
34
-
35
- class ConfigSlack(BaseModel):
36
- channel: str
37
- message: str
38
-
39
-
40
- class ConfigPhantom(BaseModel):
41
- cam_workers: str
42
- label: str
43
- phantom_server: str
44
- sensitivity: str
45
- severity: str
46
-
47
-
48
- class ConfigRba(BaseModel):
49
- enabled: str
50
-
51
-
52
- class ConfigDetectionConfiguration(BaseModel):
53
- scheduling: ConfigScheduling = ConfigScheduling(cron_schedule="0 * * * *", earliest_time="-70m@m", latest_time="-10m@m", schedule_window="auto")
54
- notable: ConfigNotable = ConfigNotable(rule_description="%description%", rule_title="%name%", nes_fields=["user", "dest", "src"])
55
- email: Union[ConfigEmail,None] = None
56
- slack: Union[ConfigSlack,None] = None
57
- phantom: Union[ConfigPhantom,None] = None
58
- rba: Union[ConfigRba,None] = None
59
-
60
-
61
- class ConfigAlertAction(BaseModel):
62
- notable: ConfigNotable
63
-
64
-
65
-
66
-
67
- class ConfigDeploy(BaseModel):
68
- description: str = "Description for this deployment target"
69
- server: str = "127.0.0.1"
70
-
71
- CREDENTIAL_MISSING = "PROVIDE_CREDENTIALS_VIA_CMD_LINE_ARGUMENT"
72
- class ConfigDeployACS(ConfigDeploy):
73
- token: str = CREDENTIAL_MISSING
14
+ from enum import StrEnum, auto
15
+ import pathlib
16
+ from contentctl.helper.utils import Utils
17
+ from urllib.parse import urlparse
18
+ from abc import ABC, abstractmethod
19
+ from contentctl.objects.enums import PostTestBehavior
20
+ from contentctl.objects.detection import Detection
21
+
22
+ import tqdm
23
+ from functools import partialmethod
24
+
25
+ ENTERPRISE_SECURITY_UID = 263
26
+ COMMON_INFORMATION_MODEL_UID = 1621
27
+
28
+ SPLUNKBASE_URL = "https://splunkbase.splunk.com/app/{uid}/release/{version}/download"
29
+
30
+ class App_Base(BaseModel,ABC):
31
+ model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
32
+ uid: Optional[int] = Field(default=None)
33
+ title: str = Field(description="Human-readable name used by the app. This can have special characters.")
34
+ appid: Optional[Annotated[str, Field(pattern="^[a-zA-Z0-9_-]+$")]]= Field(default=None,description="Internal name used by your app. "
35
+ "It may ONLY have characters, numbers, and underscores. No other characters are allowed.")
36
+ version: str = Field(description="The version of your Content Pack. This must follow semantic versioning guidelines.")
37
+ description: Optional[str] = Field(default="description of app",description="Free text description of the Content Pack.")
74
38
 
75
39
 
76
- class ConfigDeployRestAPI(ConfigDeploy):
77
- port: int = 8089
78
- username: str = "admin"
79
- password: str = PASSWORD
80
40
 
81
41
 
82
- class Deployments(BaseModel):
83
- acs_deployments: list[ConfigDeployACS] = []
84
- rest_api_deployments: list[ConfigDeployRestAPI] = [ConfigDeployRestAPI()]
42
+ def getSplunkbasePath(self)->HttpUrl:
43
+ return HttpUrl(SPLUNKBASE_URL.format(uid=self.uid, release=self.version))
85
44
 
45
+ @abstractmethod
46
+ def getApp(self, config:test, stage_file:bool=False)->str:
47
+ ...
86
48
 
49
+ def ensureAppPathExists(self, config:test, stage_file:bool=False):
50
+ if stage_file:
51
+ if not config.getLocalAppDir().exists():
52
+ config.getLocalAppDir().mkdir(parents=True)
87
53
 
88
- class ConfigBuildSplunk(BaseModel):
89
- pass
54
+ class TestApp(App_Base):
55
+ model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
56
+ hardcoded_path: Optional[Union[FilePath,HttpUrl]] = Field(default=None, description="This may be a relative or absolute link to a file OR an HTTP URL linking to your app.")
90
57
 
91
- class ConfigBuildJson(BaseModel):
92
- pass
93
-
94
- class ConfigBuildBa(BaseModel):
95
- pass
96
-
97
58
 
98
-
99
- class ConfigBuild(BaseModel):
59
+ @field_serializer('hardcoded_path',when_used='always')
60
+ def serialize_path(path: Union[AnyUrl, pathlib.Path])->str:
61
+ return str(path)
62
+
63
+ def getApp(self, config:test,stage_file:bool=False)->str:
64
+ #If the apps directory does not exist, then create it
65
+ self.ensureAppPathExists(config,stage_file)
66
+
67
+ if config.splunk_api_password is not None and config.splunk_api_username is not None:
68
+ if self.version is not None and self.uid is not None:
69
+ return str(self.getSplunkbasePath())
70
+ if self.version is None or self.uid is None:
71
+ print(f"Not downloading {self.title} from Splunkbase since uid[{self.uid}] AND version[{self.version}] MUST be defined")
72
+
73
+
74
+ elif isinstance(self.hardcoded_path, pathlib.Path):
75
+ destination = config.getLocalAppDir() / self.hardcoded_path.name
76
+ if stage_file:
77
+ Utils.copy_local_file(str(self.hardcoded_path),
78
+ str(destination),
79
+ verbose_print=True)
80
+
81
+ elif isinstance(self.hardcoded_path, AnyUrl):
82
+ file_url_string = str(self.hardcoded_path)
83
+ server_path = pathlib.Path(urlparse(file_url_string).path)
84
+ destination = config.getLocalAppDir() / server_path.name
85
+ if stage_file:
86
+ Utils.download_file_from_http(file_url_string, str(destination))
87
+ else:
88
+ raise Exception(f"Unknown path for app '{self.title}'")
89
+
90
+ return str(destination)
91
+
92
+ class CustomApp(App_Base):
93
+ model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
100
94
  # Fields required for app.conf based on
101
95
  # https://docs.splunk.com/Documentation/Splunk/9.0.4/Admin/Appconf
102
- title: str = Field(default="ContentPack",title="Internal name used by your app. No spaces or special characters.")
103
- path_root: str = Field(default="dist",title="The root path at which you will build your app.")
104
- prefix: str = Field(default="ContentPack",title="A short prefix to easily identify all your content.")
105
- build: int = Field(default=int(datetime.utcnow().strftime("%Y%m%d%H%M%S")),
106
- title="Build number for your app. This will always be a number that corresponds to the time of the build in the format YYYYMMDDHHMMSS")
107
- version: str = Field(default="0.0.1",title="The version of your Content Pack. This must follow semantic versioning guidelines.")
96
+ uid: int = Field(ge=2, lt=100000, default_factory=lambda:random.randint(20000,100000))
97
+ title: str = Field(default="Content Pack",description="Human-readable name used by the app. This can have special characters.")
98
+ appid: Annotated[str, Field(pattern="^[a-zA-Z0-9_-]+$")]= Field(default="ContentPack",description="Internal name used by your app. "
99
+ "It may ONLY have characters, numbers, and underscores. No other characters are allowed.")
100
+ version: str = Field(default="0.0.1",description="The version of your Content Pack. This must follow semantic versioning guidelines.", validate_default=True)
101
+
102
+ prefix: str = Field(default="ContentPack",description="A short prefix to easily identify all your content.")
103
+ build: int = Field(exclude=True, default=int(datetime.now(UTC).strftime("%Y%m%d%H%M%S")), validate_default=True,
104
+ description="Build number for your app. This will always be a number that corresponds to the time of the build in the format YYYYMMDDHHMMSS")
108
105
  # id has many restrictions:
109
106
  # * Omit this setting for apps that are for internal use only and not intended
110
107
  # for upload to Splunkbase.
@@ -120,54 +117,789 @@ class ConfigBuild(BaseModel):
120
117
  # * must not be any of the following names: CON, PRN, AUX, NUL,
121
118
  # COM1, COM2, COM3, COM4, COM5, COM6, COM7, COM8, COM9,
122
119
  # LPT1, LPT2, LPT3, LPT4, LPT5, LPT6, LPT7, LPT8, LPT9
123
- name: str = Field(default="ContentPack",title="Internal name used by your app. No spaces or special characters.")
124
- label: str = Field(default="Custom Splunk Content Pack",title="This is the app name that shows in the launcher.")
125
- author_name: str = Field(default="author name",title="Name of the Content Pack Author.")
126
- author_email: str = Field(default="author@contactemailaddress.com",title="Contact email for the Content Pack Author")
127
- author_company: str = Field(default="author company",title="Name of the company who has developed the Content Pack")
128
- description: str = Field(default="description of app",title="Free text description of the Content Pack.")
129
-
130
- splunk_app: Union[ConfigBuildSplunk,None] = ConfigBuildSplunk()
131
- json_objects: Union[ConfigBuildJson,None] = None
132
- ba_objects: Union[ConfigBuildBa,None] = None
133
-
134
- @validator('version', always=True)
120
+
121
+ label: str = Field(default="Custom Splunk Content Pack",description="This is the app name that shows in the launcher.")
122
+ author_name: str = Field(default="author name",description="Name of the Content Pack Author.")
123
+ author_email: str = Field(default="author@contactemailaddress.com",description="Contact email for the Content Pack Author")
124
+ author_company: str = Field(default="author company",description="Name of the company who has developed the Content Pack")
125
+ description: str = Field(default="description of app",description="Free text description of the Content Pack.")
126
+
127
+
128
+ @field_validator('version')
135
129
  def validate_version(cls, v, values):
136
130
  try:
137
- validate_version = semantic_version.Version(v)
131
+ _ = semantic_version.Version(v)
138
132
  except Exception as e:
139
133
  raise(ValueError(f"The specified version does not follow the semantic versioning spec (https://semver.org/). {str(e)}"))
140
134
  return v
141
135
 
142
136
  #Build will ALWAYS be the current utc timestamp
143
- @validator('build', always=True)
137
+ @field_validator('build')
144
138
  def validate_build(cls, v, values):
145
139
  return int(datetime.utcnow().strftime("%Y%m%d%H%M%S"))
146
140
 
141
+ def getApp(self, config:test, stage_file=True)->str:
142
+ self.ensureAppPathExists(config,stage_file)
143
+
144
+ destination = config.getLocalAppDir() / (config.getPackageFilePath(include_version=True).name)
145
+ if stage_file:
146
+ Utils.copy_local_file(str(config.getPackageFilePath(include_version=True)),
147
+ str(destination),
148
+ verbose_print=True)
149
+ return str(destination)
150
+
151
+
152
+ class Config_Base(BaseModel):
153
+ model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
154
+
155
+ path: DirectoryPath = Field(default=DirectoryPath("."), description="The root of your app.")
156
+ app:CustomApp = Field(default_factory=CustomApp)
157
+
158
+ @field_serializer('path',when_used='always')
159
+ def serialize_path(path: DirectoryPath)->str:
160
+ return str(path)
161
+
162
+ class init(Config_Base):
163
+ pass
164
+
165
+
166
+ class validate(Config_Base):
167
+ model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
168
+ enrichments: bool = Field(default=False, description="Enable MITRE, APP, and CVE Enrichments. "\
169
+ "This is useful when outputting a release build "\
170
+ "and validating these values, but should otherwise "\
171
+ "be avoided for performance reasons.")
172
+ build_app: bool = Field(default=True, description="Should an app be built and output in the build_path?")
173
+ build_api: bool = Field(default=False, description="Should api objects be built and output in the build_path?")
174
+ build_ssa: bool = Field(default=False, description="Should ssa objects be built and output in the build_path?")
175
+
176
+ def getAtomicRedTeamRepoPath(self, atomic_red_team_repo_name:str = "atomic-red-team"):
177
+ return self.path/atomic_red_team_repo_name
178
+
179
+ class report(validate):
180
+ #reporting takes no extra args, but we define it here so that it can be a mode on the command line
181
+ def getReportingPath(self)->pathlib.Path:
182
+ return self.path/"reporting/"
183
+
184
+
185
+
186
+ class build(validate):
187
+ model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
188
+ build_path: DirectoryPath = Field(default=DirectoryPath("dist/"), title="Target path for all build outputs")
189
+
190
+ @field_serializer('build_path',when_used='always')
191
+ def serialize_build_path(path: DirectoryPath)->str:
192
+ return str(path)
193
+
194
+ @field_validator('build_path',mode='before')
195
+ @classmethod
196
+ def ensure_build_path(cls, v:Union[str,DirectoryPath]):
197
+ '''
198
+ If the build path does not exist, then create it.
199
+ If the build path is actually a file, then raise a descriptive
200
+ exception.
201
+ '''
202
+ if isinstance(v,str):
203
+ v = pathlib.Path(v)
204
+ if v.is_dir():
205
+ return v
206
+ elif v.is_file():
207
+ raise ValueError(f"Build path {v} must be a directory, but instead it is a file")
208
+ elif not v.exists():
209
+ v.mkdir(parents=True)
210
+ return v
211
+
212
+ def getBuildDir(self)->pathlib.Path:
213
+ return self.path / self.build_path
214
+
215
+ def getPackageDirectoryPath(self)->pathlib.Path:
216
+ return self.getBuildDir() / f"{self.app.appid}"
217
+
218
+
219
+ def getPackageFilePath(self, include_version:bool=False)->pathlib.Path:
220
+ if include_version:
221
+ return self.getBuildDir() / f"{self.app.appid}-{self.app.version}.tar.gz"
222
+ else:
223
+ return self.getBuildDir() / f"{self.app.appid}-latest.tar.gz"
224
+
225
+ def getSSAPath(self)->pathlib.Path:
226
+ return self.getBuildDir() / "ssa"
227
+
228
+ def getAPIPath(self)->pathlib.Path:
229
+ return self.getBuildDir() / "api"
230
+
231
+ def getAppTemplatePath(self)->pathlib.Path:
232
+ return self.path/"app_template"
233
+
147
234
 
148
235
 
236
+ class StackType(StrEnum):
237
+ classic = auto()
238
+ victoria = auto()
149
239
 
150
- class ConfigEnrichments(BaseModel):
151
- attack_enrichment: bool = False
152
- cve_enrichment: bool = False
153
- splunk_app_enrichment: bool = False
240
+ class inspect(build):
241
+ splunk_api_username: str = Field(description="Splunk API username used for running appinspect.")
242
+ splunk_api_password: str = Field(exclude=True, description="Splunk API password used for running appinspect.")
243
+ stack_type: StackType = Field(description="The type of your Splunk Cloud Stack")
154
244
 
245
+ class NewContentType(StrEnum):
246
+ detection = auto()
247
+ story = auto()
155
248
 
156
- class ConfigBuildSSA(BaseModel):
157
- path_root: str
158
249
 
159
- class ConfigBuildApi(BaseModel):
160
- path_root: str
250
+ class new(Config_Base):
251
+ type: NewContentType = Field(default=NewContentType.detection, description="Specify the type of content you would like to create.")
161
252
 
162
- class Config(BaseModel, extra=Extra.forbid):
163
- #general: ConfigGlobal = ConfigGlobal()
164
- #detection_configuration: ConfigDetectionConfiguration = ConfigDetectionConfiguration()
165
- deployments: Deployments = Deployments()
166
- build: ConfigBuild = ConfigBuild()
167
- build_ssa: Union[ConfigBuildSSA,None] = None
168
- build_api: Union[ConfigBuildApi,None] = None
169
- enrichments: ConfigEnrichments = ConfigEnrichments()
170
- test: Union[TestConfig,None] = None
253
+
254
+ class deploy_acs(inspect):
255
+ model_config = ConfigDict(use_enum_values=True,validate_default=False, arbitrary_types_allowed=True)
256
+ #ignore linter error
257
+ splunk_cloud_jwt_token: str = Field(exclude=True, description="Splunk JWT used for performing ACS operations on a Splunk Cloud Instance")
258
+ splunk_cloud_stack: str = Field(description="The name of your Splunk Cloud Stack")
259
+
260
+
261
+ class Infrastructure(BaseModel):
262
+ model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
263
+ splunk_app_username:str = Field(default="admin", description="Username for logging in to your Splunk Server")
264
+ splunk_app_password:str = Field(exclude=True, default="password", description="Password for logging in to your Splunk Server.")
265
+ instance_address:str = Field(..., description="Address of your splunk server.")
266
+ hec_port: int = Field(default=8088, gt=1, lt=65536, title="HTTP Event Collector Port")
267
+ web_ui_port: int = Field(default=8000, gt=1, lt=65536, title="Web UI Port")
268
+ api_port: int = Field(default=8089, gt=1, lt=65536, title="REST API Port")
269
+ instance_name: str = Field(...)
270
+
271
+
272
+ class deploy_rest(build):
273
+ model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
274
+
275
+ target:Infrastructure = Infrastructure(instance_name="splunk_target_host", instance_address="localhost")
276
+ #This will overwrite existing content without promprting for confirmation
277
+ overwrite_existing_content:bool = Field(default=True, description="Overwrite existing macros and savedsearches in your enviornment")
278
+
279
+
280
+ class Container(Infrastructure):
281
+ model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
282
+ instance_address:str = Field(default="localhost", description="Address of your splunk server.")
283
+
284
+
285
+ class ContainerSettings(BaseModel):
286
+ model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
287
+ leave_running: bool = Field(default=True, description="Leave container running after it is first "
288
+ "set up to speed up subsequent test runs.")
289
+ num_containers: PositiveInt = Field(default=1, description="Number of containers to start in parallel. "
290
+ "Please note that each container is quite expensive to run. It is not "
291
+ "recommended to run more than 4 containers unless you have a very "
292
+ "well-resourced environment.")
293
+ full_image_path:str = Field(default="registry.hub.docker.com/splunk/splunk:latest",
294
+ title="Full path to the container image to be used")
295
+
296
+ def getContainers(self)->List[Container]:
297
+ containers = []
298
+ for i in range(self.num_containers):
299
+ containers.append(Container(instance_name="contentctl_{}".format(i),
300
+ web_ui_port=8000+i, hec_port=8088+(i*2), api_port=8089+(i*2)))
301
+
302
+ return containers
303
+
304
+
305
+ class All(BaseModel):
306
+ #Doesn't need any extra logic
307
+ pass
308
+
309
+ class Changes(BaseModel):
310
+ model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
311
+ target_branch:str = Field(...,description="The target branch to diff against. Note that this includes uncommitted changes in the working directory as well.")
312
+
313
+
314
+ class Selected(BaseModel):
315
+ model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
316
+ files:List[FilePath] = Field(...,description="List of detection files to test, separated by spaces.")
317
+
318
+ @field_serializer('files',when_used='always')
319
+ def serialize_path(paths: List[FilePath])->List[str]:
320
+ return [str(path) for path in paths]
321
+
322
+ DEFAULT_APPS:List[TestApp] = [
323
+ TestApp(
324
+ uid=1621,
325
+ appid="Splunk_SA_CIM",
326
+ title="Splunk Common Information Model (CIM)",
327
+ version="5.2.0",
328
+ hardcoded_path=HttpUrl(
329
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/splunk-common-information-model-cim_520.tgz"
330
+ ),
331
+ ),
332
+ TestApp(
333
+ uid=6553,
334
+ appid="Splunk_TA_okta_identity_cloud",
335
+ title="Splunk Add-on for Okta Identity Cloud",
336
+ version="2.1.0",
337
+ hardcoded_path=HttpUrl(
338
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/splunk-add-on-for-okta-identity-cloud_210.tgz"
339
+ ),
340
+ ),
341
+ TestApp(
342
+ uid=6176,
343
+ appid="Splunk_TA_linux_sysmon",
344
+ title="Add-on for Linux Sysmon",
345
+ version="1.0.4",
346
+ hardcoded_path=HttpUrl(
347
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/add-on-for-linux-sysmon_104.tgz"
348
+ ),
349
+ ),
350
+ TestApp(
351
+ appid="Splunk_FIX_XMLWINEVENTLOG_HEC_PARSING",
352
+ title="Splunk Fix XmlWinEventLog HEC Parsing",
353
+ version="0.1",
354
+ description="This TA is required for replaying Windows Data into the Test Environment. The Default TA does not include logic for properly splitting multiple log events in a single file. In production environments, this logic is applied by the Universal Forwarder.",
355
+ hardcoded_path=HttpUrl(
356
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/Splunk_TA_fix_windows.tgz"
357
+ ),
358
+ ),
359
+ TestApp(
360
+ uid=742,
361
+ appid="SPLUNK_ADD_ON_FOR_MICROSOFT_WINDOWS",
362
+ title="Splunk Add-on for Microsoft Windows",
363
+ version="8.8.0",
364
+ hardcoded_path=HttpUrl(
365
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/splunk-add-on-for-microsoft-windows_880.tgz"
366
+ ),
367
+ ),
368
+ TestApp(
369
+ uid=5709,
370
+ appid="Splunk_TA_microsoft_sysmon",
371
+ title="Splunk Add-on for Sysmon",
372
+ version="4.0.0",
373
+ hardcoded_path=HttpUrl(
374
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/splunk-add-on-for-sysmon_400.tgz"
375
+ ),
376
+ ),
377
+ TestApp(
378
+ uid=833,
379
+ appid="Splunk_TA_nix",
380
+ title="Splunk Add-on for Unix and Linux",
381
+ version="9.0.0",
382
+ hardcoded_path=HttpUrl(
383
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/splunk-add-on-for-unix-and-linux_900.tgz"
384
+ ),
385
+ ),
386
+ TestApp(
387
+ uid=5579,
388
+ appid="Splunk_TA_CrowdStrike_FDR",
389
+ title="Splunk Add-on for CrowdStrike FDR",
390
+ version="1.5.0",
391
+ hardcoded_path=HttpUrl(
392
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/splunk-add-on-for-crowdstrike-fdr_150.tgz"
393
+ ),
394
+ ),
395
+ TestApp(
396
+ uid=3185,
397
+ appid="SPLUNK_TA_FOR_IIS",
398
+ title="Splunk Add-on for Microsoft IIS",
399
+ version="1.3.0",
400
+ hardcoded_path=HttpUrl(
401
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/splunk-add-on-for-microsoft-iis_130.tgz"
402
+ ),
403
+ ),
404
+ TestApp(
405
+ uid=4242,
406
+ appid="SPLUNK_TA_FOR_SURICATA",
407
+ title="TA for Suricata",
408
+ version="2.3.4",
409
+ hardcoded_path=HttpUrl(
410
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/ta-for-suricata_234.tgz"
411
+ ),
412
+ ),
413
+ TestApp(
414
+ uid=5466,
415
+ appid="SPLUNK_TA_FOR_ZEEK",
416
+ title="TA for Zeek",
417
+ version="1.0.6",
418
+ hardcoded_path=HttpUrl(
419
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/ta-for-zeek_106.tgz"
420
+ ),
421
+ ),
422
+ TestApp(
423
+ uid=3258,
424
+ appid="SPLUNK_ADD_ON_FOR_NGINX",
425
+ title="Splunk Add-on for NGINX",
426
+ version="3.2.2",
427
+ hardcoded_path=HttpUrl(
428
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/splunk-add-on-for-nginx_322.tgz"
429
+ ),
430
+ ),
431
+ TestApp(
432
+ uid=5238,
433
+ appid="SPLUNK_ADD_ON_FOR_STREAM_FORWARDERS",
434
+ title="Splunk Add-on for Stream Forwarders",
435
+ version="8.1.1",
436
+ hardcoded_path=HttpUrl(
437
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/splunk-add-on-for-stream-forwarders_811.tgz"
438
+ ),
439
+ ),
440
+ TestApp(
441
+ uid=5234,
442
+ appid="SPLUNK_ADD_ON_FOR_STREAM_WIRE_DATA",
443
+ title="Splunk Add-on for Stream Wire Data",
444
+ version="8.1.1",
445
+ hardcoded_path=HttpUrl(
446
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/splunk-add-on-for-stream-wire-data_811.tgz"
447
+ ),
448
+ ),
449
+ TestApp(
450
+ uid=2757,
451
+ appid="PALO_ALTO_NETWORKS_ADD_ON_FOR_SPLUNK",
452
+ title="Palo Alto Networks Add-on for Splunk",
453
+ version="8.1.1",
454
+ hardcoded_path=HttpUrl(
455
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/palo-alto-networks-add-on-for-splunk_811.tgz"
456
+ ),
457
+ ),
458
+ TestApp(
459
+ uid=3865,
460
+ appid="Zscaler_CIM",
461
+ title="Zscaler Technical Add-On for Splunk",
462
+ version="4.0.3",
463
+ hardcoded_path=HttpUrl(
464
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/zscaler-technical-add-on-for-splunk_403.tgz"
465
+ ),
466
+ ),
467
+ TestApp(
468
+ uid=3719,
469
+ appid="SPLUNK_ADD_ON_FOR_AMAZON_KINESIS_FIREHOSE",
470
+ title="Splunk Add-on for Amazon Kinesis Firehose",
471
+ version="1.3.2",
472
+ hardcoded_path=HttpUrl(
473
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/splunk-add-on-for-amazon-kinesis-firehose_132.tgz"
474
+ ),
475
+ ),
476
+ TestApp(
477
+ uid=1876,
478
+ appid="Splunk_TA_aws",
479
+ title="Splunk Add-on for AWS",
480
+ version="7.5.0",
481
+ hardcoded_path=HttpUrl(
482
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/splunk-add-on-for-amazon-web-services-aws_750.tgz"
483
+ ),
484
+ ),
485
+ TestApp(
486
+ uid=3088,
487
+ appid="SPLUNK_ADD_ON_FOR_GOOGLE_CLOUD_PLATFORM",
488
+ title="Splunk Add-on for Google Cloud Platform",
489
+ version="4.4.0",
490
+ hardcoded_path=HttpUrl(
491
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/splunk-add-on-for-google-cloud-platform_440.tgz"
492
+ ),
493
+ ),
494
+ TestApp(
495
+ uid=5556,
496
+ appid="SPLUNK_ADD_ON_FOR_GOOGLE_WORKSPACE",
497
+ title="Splunk Add-on for Google Workspace",
498
+ version="2.6.3",
499
+ hardcoded_path=HttpUrl(
500
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/splunk-add-on-for-google-workspace_263.tgz"
501
+ ),
502
+ ),
503
+ TestApp(
504
+ uid=3110,
505
+ appid="SPLUNK_TA_MICROSOFT_CLOUD_SERVICES",
506
+ title="Splunk Add-on for Microsoft Cloud Services",
507
+ version="5.2.2",
508
+ hardcoded_path=HttpUrl(
509
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/splunk-add-on-for-microsoft-cloud-services_522.tgz"
510
+ ),
511
+ ),
512
+ TestApp(
513
+ uid=4055,
514
+ appid="SPLUNK_ADD_ON_FOR_MICROSOFT_OFFICE_365",
515
+ title="Splunk Add-on for Microsoft Office 365",
516
+ version="4.5.1",
517
+ hardcoded_path=HttpUrl(
518
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/splunk-add-on-for-microsoft-office-365_451.tgz"
519
+ ),
520
+ ),
521
+ TestApp(
522
+ uid=2890,
523
+ appid="SPLUNK_MACHINE_LEARNING_TOOLKIT",
524
+ title="Splunk Machine Learning Toolkit",
525
+ version="5.4.1",
526
+ hardcoded_path=HttpUrl(
527
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/splunk-machine-learning-toolkit_541.tgz"
528
+ ),
529
+ ),
530
+ TestApp(
531
+ uid=2734,
532
+ appid="URL_TOOLBOX",
533
+ title="URL Toolbox",
534
+ version="1.9.2",
535
+ hardcoded_path=HttpUrl(
536
+ "https://attack-range-appbinaries.s3.us-west-2.amazonaws.com/Latest/url-toolbox_192.tgz"
537
+ ),
538
+ ),
539
+ ]
540
+
541
+ class test_common(build):
542
+ mode:Union[Changes, Selected, All] = Field(All(), union_mode='left_to_right')
543
+ post_test_behavior: PostTestBehavior = Field(default=PostTestBehavior.pause_on_failure, description="Controls what to do when a test completes.\n\n"
544
+ f"'{PostTestBehavior.always_pause.value}' - the state of "
545
+ "the test will always pause after a test, allowing the user to log into the "
546
+ "server and experiment with the search and data before it is removed.\n\n"
547
+ f"'{PostTestBehavior.pause_on_failure.value}' - pause execution ONLY when a test fails. The user may press ENTER in the terminal "
548
+ "running the test to move on to the next test.\n\n"
549
+ f"'{PostTestBehavior.never_pause.value}' - never stop testing, even if a test fails.\n\n"
550
+ "***SPECIAL NOTE FOR CI/CD*** 'never_pause' MUST be used for a test to "
551
+ "run in an unattended manner or in a CI/CD system - otherwise a single failed test "
552
+ "will result in the testing never finishing as the tool waits for input.")
553
+ test_instances:List[Infrastructure] = Field(...)
554
+ enable_integration_testing: bool = Field(default=False, description="Enable integration testing, which REQUIRES Splunk Enterprise Security "
555
+ "to be installed on the server. This checks for a number of different things including generation "
556
+ "of appropriate notables and messages. Please note that this will increase testing time "
557
+ "considerably (by approximately 2-3 minutes per detection).")
558
+ plan_only:bool = Field(default=False, exclude=True, description="WARNING - This is an advanced feature and currently intended for widespread use. "
559
+ "This flag is useful for building your app and generating a test plan to run on different infrastructure. "
560
+ "This flag does not actually perform the test. Instead, it builds validates all content and builds the app(s). "
561
+ "It MUST be used with mode.changes and must run in the context of a git repo.")
562
+ disable_tqdm:bool = Field(default=False, exclude=True, description="The tdqm library (https://github.com/tqdm/tqdm) is used to facilitate a richer,"
563
+ " interactive command line workflow that can display progress bars and status information frequently. "
564
+ "Unfortunately it is incompatible with, or may cause poorly formatted logs, in many CI/CD systems or other unattended environments. "
565
+ "If you are running contentctl in CI/CD, then please set this argument to True. Note that if you are running in a CI/CD context, "
566
+ f"you also MUST set post_test_behavior to {PostTestBehavior.never_pause.value}. Otherwiser, a failed detection will cause"
567
+ "the CI/CD running to pause indefinitely.")
568
+
569
+ apps: List[TestApp] = Field(default=DEFAULT_APPS, exclude=False, description="List of apps to install in test environment")
570
+
571
+
572
+ def dumpCICDPlanAndQuit(self, githash: str, detections:List[Detection]):
573
+ output_file = self.path / "test_plan.yml"
574
+ self.mode = Selected(files=sorted([detection.file_path for detection in detections], key=lambda path: str(path)))
575
+ self.post_test_behavior = PostTestBehavior.never_pause.value
576
+ #required so that CI/CD does not get too much output or hang
577
+ self.disable_tqdm = True
578
+
579
+ # We will still parse the app, but no need to do enrichments or
580
+ # output to dist. We have already built it!
581
+ self.build_app = False
582
+ self.build_api = False
583
+ self.build_ssa = False
584
+ self.enrichments = False
585
+
586
+ self.enable_integration_testing = True
587
+
588
+ data = self.model_dump()
589
+
590
+ #Add the hash of the current commit
591
+ data['githash'] = str(githash)
592
+
593
+ #Remove some fields that are not relevant
594
+ for k in ['container_settings', 'test_instances']:
595
+ if k in data:
596
+ del(data[k])
597
+
598
+
599
+
600
+ try:
601
+ YmlWriter.writeYmlFile(str(output_file), data)
602
+ print(f"Successfully wrote a test plan for [{len(self.mode.files)} detections] using [{len(self.apps)} apps] to [{output_file}]")
603
+ except Exception as e:
604
+ raise Exception(f"Error writing test plan file [{output_file}]: {str(e)}")
605
+
606
+
607
+ def getLocalAppDir(self)->pathlib.Path:
608
+ #docker really wants abolsute paths
609
+ path = self.path / "apps"
610
+ return path.absolute()
611
+
612
+ def getContainerAppDir(self)->pathlib.Path:
613
+ #docker really wants abolsute paths
614
+ return pathlib.Path("/tmp/apps").absolute()
615
+
616
+ def enterpriseSecurityInApps(self)->bool:
617
+
618
+ for app in self.apps:
619
+ if app.uid == ENTERPRISE_SECURITY_UID:
620
+ return True
621
+ return False
622
+
623
+ def commonInformationModelInApps(self)->bool:
624
+ for app in self.apps:
625
+ if app.uid == COMMON_INFORMATION_MODEL_UID:
626
+ return True
627
+ return False
628
+
629
+ @model_validator(mode='after')
630
+ def ensureCommonInformationModel(self)->Self:
631
+ if self.commonInformationModelInApps():
632
+ return self
633
+ print(f"INFO: Common Information Model/CIM "
634
+ f"(uid: [{COMMON_INFORMATION_MODEL_UID}]) is not listed in apps.\n"
635
+ f"contentctl test MUST include Common Information Model.\n"
636
+ f"Please note this message is only informational.")
637
+ return self
171
638
 
639
+ @model_validator(mode='after')
640
+ def suppressTQDM(self)->Self:
641
+ if self.disable_tqdm:
642
+ tqdm.tqdm.__init__ = partialmethod(tqdm.tqdm.__init__, disable=True)
643
+ if self.post_test_behavior != PostTestBehavior.never_pause.value:
644
+ raise ValueError(f"You have disabled tqdm, presumably because you are "
645
+ f"running in CI/CD or another unattended context.\n"
646
+ f"However, post_test_behavior is set to [{self.post_test_behavior}].\n"
647
+ f"If that is the case, then you MUST set post_test_behavior "
648
+ f"to [{PostTestBehavior.never_pause.value}].\n"
649
+ "Otherwise, if a detection fails in CI/CD, your CI/CD runner will hang forever.")
650
+ return self
651
+
652
+
653
+
654
+ @model_validator(mode='after')
655
+ def ensureEnterpriseSecurityForIntegrationTesting(self)->Self:
656
+ if not self.enable_integration_testing:
657
+ return self
658
+ if self.enterpriseSecurityInApps():
659
+ return self
660
+
661
+ print(f"INFO: enable_integration_testing is [{self.enable_integration_testing}], "
662
+ f"but the Splunk Enterprise Security "
663
+ f"App (uid: [{ENTERPRISE_SECURITY_UID}]) is not listed in apps.\n"
664
+ f"Integration Testing MUST include Enterprise Security.\n"
665
+ f"Please note this message is only informational.")
666
+ return self
667
+
668
+
669
+
670
+ @model_validator(mode='after')
671
+ def checkPlanOnlyUse(self)->Self:
672
+ #Ensure that mode is CHANGES
673
+ if self.plan_only and not isinstance(self.mode, Changes):
674
+ raise ValueError("plan_only MUST be used with --mode:changes")
675
+ return self
676
+
677
+
678
+ def getModeName(self)->str:
679
+ if isinstance(self.mode, All):
680
+ return "All"
681
+ elif isinstance(self.mode, Changes):
682
+ return "Changes"
683
+ else:
684
+ return "Selected"
685
+
686
+
687
+
688
+
689
+
690
+
691
+ class test(test_common):
692
+ model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
693
+ container_settings:ContainerSettings = ContainerSettings()
694
+ test_instances: List[Container] = Field([], exclude = True, validate_default=True)
695
+ splunk_api_username: Optional[str] = Field(default=None, exclude = True,description="Splunk API username used for running appinspect or installating apps from Splunkbase")
696
+ splunk_api_password: Optional[str] = Field(default=None, exclude = True, description="Splunk API password used for running appinspect or installaing apps from Splunkbase")
697
+
698
+
699
+ def getContainerInfrastructureObjects(self)->Self:
700
+ try:
701
+ self.test_instances = self.container_settings.getContainers()
702
+ return self
703
+
704
+ except Exception as e:
705
+ raise ValueError(f"Error constructing container test_instances: {str(e)}")
706
+
707
+
708
+
709
+
710
+ @model_validator(mode='after')
711
+ def ensureAppsAreGood(self)->Self:
712
+ """
713
+ This function ensures that, after the rest of the configuration
714
+ has been validated, all of the apps are able to be correctly resolved.
715
+ This includes apps that may be sourced from local files, HTTP files,
716
+ and/or Splunkbase.
717
+
718
+ This is NOT a model_post_init function because it does perform some validation,
719
+ even though it does not change the object
720
+
721
+ Raises:
722
+ Exception: There was a failure in parsing/validating all referenced apps
723
+
724
+ Returns:
725
+ Self: The test object. No modifications are made during this call.
726
+ """
727
+ try:
728
+ _ = self.getContainerEnvironmentString(stage_file=False, include_custom_app=False)
729
+ except Exception as e:
730
+ raise Exception(f"Error validating test apps: {str(e)}")
731
+ return self
732
+
733
+
734
+ def getContainerEnvironmentString(self,stage_file:bool=False, include_custom_app:bool=True)->str:
735
+ apps:List[App_Base] = self.apps
736
+ if include_custom_app:
737
+ apps.append(self.app)
738
+
739
+ paths = [app.getApp(self,stage_file=stage_file) for app in apps]
740
+
741
+ container_paths = []
742
+ for path in paths:
743
+ if path.startswith(SPLUNKBASE_URL):
744
+ container_paths.append(path)
745
+ else:
746
+ container_paths.append(str(self.getContainerAppDir()/pathlib.Path(path).name))
747
+
748
+ return ','.join(container_paths)
749
+
750
+ def getAppFilePath(self):
751
+ return self.path / "apps.yml"
752
+
753
+
754
+ TEST_ARGS_ENV = "CONTENTCTL_TEST_INFRASTRUCTURES"
755
+ class test_servers(test_common):
756
+ model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
757
+ test_instances:List[Infrastructure] = Field([],description="Test against one or more preconfigured servers.", validate_default=True)
758
+ server_info:Optional[str] = Field(None, validate_default=True, description='String of pre-configured servers to use for testing. The list MUST be in the format:\n'
759
+ 'address,username,web_ui_port,hec_port,api_port;address_2,username_2,web_ui_port_2,hec_port_2,api_port_2'
760
+ '\nFor example, the following string will use 2 preconfigured test instances:\n'
761
+ '127.0.0.1,firstUser,firstUserPassword,8000,8088,8089;1.2.3.4,secondUser,secondUserPassword,8000,8088,8089\n'
762
+ 'Note that these test_instances may be hosted on the same system, such as localhost/127.0.0.1 or a docker server, or different hosts.\n'
763
+ f'This value may also be passed by setting the environment variable [{TEST_ARGS_ENV}] with the value above.')
764
+
765
+ @model_validator(mode='before')
766
+ @classmethod
767
+ def parse_config(cls, data:Any, info: ValidationInfo)->Any:
768
+ #Ignore whatever is in the file or defaults, these must be supplied on command line
769
+ #if len(v) != 0:
770
+ # return v
771
+
772
+
773
+ if isinstance(data.get("server_info"),str) :
774
+ server_info = data.get("server_info")
775
+ elif isinstance(environ.get(TEST_ARGS_ENV),str):
776
+ server_info = environ.get(TEST_ARGS_ENV)
777
+ else:
778
+ raise ValueError(f"server_info not passed on command line or in environment variable {TEST_ARGS_ENV}")
779
+
780
+ infrastructures:List[Infrastructure] = []
781
+
782
+
783
+ index = 0
784
+ for server in server_info.split(';'):
785
+ address, username, password, web_ui_port, hec_port, api_port = server.split(",")
786
+ infrastructures.append(Infrastructure(splunk_app_username = username, splunk_app_password=password,
787
+ instance_address=address, hec_port = int(hec_port),
788
+ web_ui_port= int(web_ui_port),api_port=int(api_port), instance_name=f"test_server_{index}")
789
+ )
790
+ index+=1
791
+ data['test_instances'] = infrastructures
792
+ return data
793
+
794
+ @field_validator('test_instances',mode='before')
795
+ @classmethod
796
+ def check_environment_variable_for_config(cls, v:List[Infrastructure]):
797
+ return v
798
+ #Ignore whatever is in the file or defaults, these must be supplied on command line
799
+ #if len(v) != 0:
800
+ # return v
801
+ TEST_ARGS_ENV = "CONTENTCTL_TEST_INFRASTRUCTURES"
802
+
803
+
804
+ #environment variable is present. try to parse it
805
+ infrastructures:List[Infrastructure] = []
806
+ server_info:str|None = environ.get(TEST_ARGS_ENV)
807
+ if server_info is None:
808
+ raise ValueError(f"test_instances not passed on command line or in environment variable {TEST_ARGS_ENV}")
809
+
810
+
811
+ index = 0
812
+ for server in server_info.split(';'):
813
+ address, username, password, web_ui_port, hec_port, api_port = server.split(",")
814
+ infrastructures.append(Infrastructure(splunk_app_username = username, splunk_app_password=password,
815
+ instance_address=address, hec_port = int(hec_port),
816
+ web_ui_port= int(web_ui_port),api_port=int(api_port), instance_name=f"test_server_{index}")
817
+ )
818
+ index+=1
819
+
820
+
821
+
822
+ class release_notes(Config_Base):
823
+ old_tag:Optional[str] = Field(None, description="Name of the tag to diff against to find new content. "
824
+ "If it is not supplied, then it will be inferred as the "
825
+ "second newest tag at runtime.")
826
+ new_tag:Optional[str] = Field(None, description="Name of the tag containing new content. If it is not supplied,"
827
+ " then it will be inferred as the newest tag at runtime.")
828
+ latest_branch:Optional[str] = Field(None, description="Branch for which we are generating release notes")
829
+
830
+ def releaseNotesFilename(self, filename:str)->pathlib.Path:
831
+ #Assume that notes are written to dist/. This does not respect build_dir since that is
832
+ #only a member of build
833
+ p = self.path / "dist"
834
+ try:
835
+ p.mkdir(exist_ok=True,parents=True)
836
+ except Exception:
837
+ raise Exception(f"Error making the directory '{p}' to hold release_notes: {str(e)}")
838
+ return p/filename
839
+
840
+ @model_validator(mode='after')
841
+ def ensureNewTagOrLatestBranch(self):
842
+ '''
843
+ Exactly one of latest_branch or new_tag must be defined. otherwise, throw an error
844
+ '''
845
+ if self.new_tag is not None and self.latest_branch is not None:
846
+ raise ValueError("Both new_tag and latest_branch are defined. EXACTLY one of these MUST be defiend.")
847
+ elif self.new_tag is None and self.latest_branch is None:
848
+ raise ValueError("Neither new_tag nor latest_branch are defined. EXACTLY one of these MUST be defined.")
849
+ return self
850
+
851
+ # @model_validator(mode='after')
852
+ # def ensureTagsAndBranch(self)->Self:
853
+ # #get the repo
854
+ # import pygit2
855
+ # from pygit2 import Commit
856
+ # repo = pygit2.Repository(path=str(self.path))
857
+ # tags = list(repo.references.iterator(references_return_type=pygit2.enums.ReferenceFilter.TAGS))
858
+
859
+ # #Sort all tags by commit time from newest to oldest
860
+ # sorted_tags = sorted(tags, key=lambda tag: repo.lookup_reference(tag.name).peel(Commit).commit_time, reverse=True)
861
+
862
+
863
+ # tags_names:List[str] = [t.shorthand for t in sorted_tags]
864
+ # print(tags_names)
865
+ # if self.new_tag is not None and self.new_tag not in tags_names:
866
+ # raise ValueError(f"The new_tag '{self.new_tag}' was not found in the set name tags for this repo: {tags_names}")
867
+ # elif self.new_tag is None:
868
+ # try:
869
+ # self.new_tag = tags_names[0]
870
+ # except Exception:
871
+ # raise ValueError("Error getting new_tag - there were no tags in the repo")
872
+ # elif self.new_tag in tags_names:
873
+ # pass
874
+ # else:
875
+ # raise ValueError(f"Unknown error getting new_tag {self.new_tag}")
876
+
877
+
878
+
879
+ # if self.old_tag is not None and self.old_tag not in tags_names:
880
+ # raise ValueError(f"The old_tag '{self.new_tag}' was not found in the set name tags for this repo: {tags_names}")
881
+ # elif self.new_tag == self.old_tag:
882
+ # raise ValueError(f"old_tag '{self.old_tag}' cannot equal new_tag '{self.new_tag}'")
883
+ # elif self.old_tag is None:
884
+ # try:
885
+ # self.old_tag = tags_names[tags_names.index(self.new_tag) + 1]
886
+ # except Exception:
887
+ # raise ValueError(f"Error getting old_tag. new_tag '{self.new_tag}' is the oldest tag in the repo.")
888
+ # elif self.old_tag in tags_names:
889
+ # pass
890
+ # else:
891
+ # raise ValueError(f"Unknown error getting old_tag {self.old_tag}")
892
+
893
+
894
+
895
+ # if not tags_names.index(self.new_tag) < tags_names.index(self.old_tag):
896
+ # raise ValueError(f"The new_tag '{self.new_tag}' is not newer than the old_tag '{self.old_tag}'")
897
+
898
+ # if self.latest_branch is not None:
899
+ # if repo.lookup_branch(self.latest_branch) is None:
900
+ # raise ValueError("The latest_branch '{self.latest_branch}' was not found in the repository")
901
+
902
+
903
+ # return self
172
904
 
173
905