contentctl 5.0.0a2__py3-none-any.whl → 5.0.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- contentctl/__init__.py +1 -1
- contentctl/actions/build.py +88 -55
- contentctl/actions/deploy_acs.py +29 -24
- contentctl/actions/detection_testing/DetectionTestingManager.py +66 -41
- contentctl/actions/detection_testing/GitService.py +2 -4
- contentctl/actions/detection_testing/generate_detection_coverage_badge.py +48 -30
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +163 -124
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +45 -32
- contentctl/actions/detection_testing/progress_bar.py +3 -0
- contentctl/actions/detection_testing/views/DetectionTestingView.py +15 -18
- contentctl/actions/detection_testing/views/DetectionTestingViewCLI.py +1 -5
- contentctl/actions/detection_testing/views/DetectionTestingViewFile.py +2 -2
- contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py +1 -4
- contentctl/actions/doc_gen.py +9 -5
- contentctl/actions/initialize.py +45 -33
- contentctl/actions/inspect.py +118 -61
- contentctl/actions/new_content.py +83 -53
- contentctl/actions/release_notes.py +276 -146
- contentctl/actions/reporting.py +23 -19
- contentctl/actions/test.py +31 -25
- contentctl/actions/validate.py +54 -34
- contentctl/api.py +54 -45
- contentctl/contentctl.py +10 -10
- contentctl/enrichments/attack_enrichment.py +112 -72
- contentctl/enrichments/cve_enrichment.py +34 -28
- contentctl/enrichments/splunk_app_enrichment.py +38 -36
- contentctl/helper/link_validator.py +101 -78
- contentctl/helper/splunk_app.py +69 -41
- contentctl/helper/utils.py +58 -39
- contentctl/input/director.py +69 -37
- contentctl/input/new_content_questions.py +26 -34
- contentctl/input/yml_reader.py +22 -17
- contentctl/objects/abstract_security_content_objects/detection_abstract.py +255 -323
- contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +111 -46
- contentctl/objects/alert_action.py +8 -8
- contentctl/objects/annotated_types.py +1 -1
- contentctl/objects/atomic.py +64 -54
- contentctl/objects/base_test.py +2 -1
- contentctl/objects/base_test_result.py +16 -8
- contentctl/objects/baseline.py +47 -35
- contentctl/objects/baseline_tags.py +29 -22
- contentctl/objects/config.py +1 -1
- contentctl/objects/constants.py +32 -58
- contentctl/objects/correlation_search.py +75 -55
- contentctl/objects/dashboard.py +55 -41
- contentctl/objects/data_source.py +13 -13
- contentctl/objects/deployment.py +44 -37
- contentctl/objects/deployment_email.py +1 -1
- contentctl/objects/deployment_notable.py +2 -1
- contentctl/objects/deployment_phantom.py +5 -5
- contentctl/objects/deployment_rba.py +1 -1
- contentctl/objects/deployment_scheduling.py +1 -1
- contentctl/objects/deployment_slack.py +1 -1
- contentctl/objects/detection.py +5 -2
- contentctl/objects/detection_metadata.py +1 -0
- contentctl/objects/detection_stanza.py +7 -2
- contentctl/objects/detection_tags.py +54 -64
- contentctl/objects/drilldown.py +66 -35
- contentctl/objects/enums.py +61 -43
- contentctl/objects/errors.py +16 -24
- contentctl/objects/integration_test.py +3 -3
- contentctl/objects/integration_test_result.py +1 -0
- contentctl/objects/investigation.py +53 -31
- contentctl/objects/investigation_tags.py +29 -17
- contentctl/objects/lookup.py +234 -113
- contentctl/objects/macro.py +55 -38
- contentctl/objects/manual_test.py +3 -3
- contentctl/objects/manual_test_result.py +1 -0
- contentctl/objects/mitre_attack_enrichment.py +17 -16
- contentctl/objects/notable_action.py +2 -1
- contentctl/objects/notable_event.py +1 -3
- contentctl/objects/playbook.py +37 -35
- contentctl/objects/playbook_tags.py +22 -16
- contentctl/objects/rba.py +68 -11
- contentctl/objects/risk_analysis_action.py +15 -11
- contentctl/objects/risk_event.py +27 -20
- contentctl/objects/risk_object.py +1 -0
- contentctl/objects/savedsearches_conf.py +9 -7
- contentctl/objects/security_content_object.py +5 -2
- contentctl/objects/story.py +54 -49
- contentctl/objects/story_tags.py +56 -44
- contentctl/objects/test_group.py +5 -2
- contentctl/objects/threat_object.py +1 -0
- contentctl/objects/throttling.py +27 -18
- contentctl/objects/unit_test.py +3 -4
- contentctl/objects/unit_test_baseline.py +4 -5
- contentctl/objects/unit_test_result.py +6 -6
- contentctl/output/api_json_output.py +22 -22
- contentctl/output/attack_nav_output.py +21 -21
- contentctl/output/attack_nav_writer.py +29 -37
- contentctl/output/conf_output.py +230 -174
- contentctl/output/data_source_writer.py +38 -25
- contentctl/output/doc_md_output.py +53 -27
- contentctl/output/jinja_writer.py +19 -15
- contentctl/output/json_writer.py +20 -8
- contentctl/output/svg_output.py +56 -38
- contentctl/output/templates/analyticstories_detections.j2 +1 -1
- contentctl/output/templates/analyticstories_stories.j2 +1 -1
- contentctl/output/templates/es_investigations_investigations.j2 +1 -1
- contentctl/output/templates/es_investigations_stories.j2 +1 -1
- contentctl/output/templates/savedsearches_baselines.j2 +2 -2
- contentctl/output/templates/savedsearches_detections.j2 +2 -8
- contentctl/output/templates/savedsearches_investigations.j2 +2 -2
- contentctl/output/templates/transforms.j2 +2 -4
- contentctl/output/yml_writer.py +18 -24
- contentctl/templates/stories/cobalt_strike.yml +1 -0
- {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/METADATA +1 -1
- contentctl-5.0.1.dist-info/RECORD +168 -0
- contentctl/actions/initialize_old.py +0 -245
- contentctl/objects/observable.py +0 -39
- contentctl-5.0.0a2.dist-info/RECORD +0 -170
- {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/LICENSE.md +0 -0
- {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/WHEEL +0 -0
- {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/entry_points.txt +0 -0
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
from pydantic import BaseModel, model_validator
|
|
2
2
|
from typing import Union, Callable, Any
|
|
3
3
|
import requests
|
|
4
|
-
import urllib3
|
|
4
|
+
import urllib3
|
|
5
|
+
import urllib3.exceptions
|
|
5
6
|
import time
|
|
6
7
|
import abc
|
|
7
8
|
|
|
@@ -10,88 +11,96 @@ import shelve
|
|
|
10
11
|
|
|
11
12
|
DEFAULT_USER_AGENT_STRING = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.41 Safari/537.36"
|
|
12
13
|
ALLOWED_HTTP_CODES = [200]
|
|
13
|
-
class LinkStats(BaseModel):
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
|
|
16
|
+
class LinkStats(BaseModel):
|
|
17
|
+
# Static Values
|
|
16
18
|
method: Callable = requests.get
|
|
17
|
-
allowed_http_codes: list[int] = ALLOWED_HTTP_CODES
|
|
18
|
-
access_count: int = 1
|
|
19
|
+
allowed_http_codes: list[int] = ALLOWED_HTTP_CODES
|
|
20
|
+
access_count: int = 1 # when constructor is called, it has been accessed once!
|
|
19
21
|
timeout_seconds: int = 15
|
|
20
22
|
allow_redirects: bool = True
|
|
21
23
|
headers: dict = {"User-Agent": DEFAULT_USER_AGENT_STRING}
|
|
22
24
|
verify_ssl: bool = False
|
|
23
25
|
if verify_ssl is False:
|
|
24
26
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
25
|
-
|
|
26
|
-
#Values generated at runtime.
|
|
27
|
-
#We need to assign these some default values to get the
|
|
28
|
-
#validation working since ComputedField has not yet been
|
|
29
|
-
#introduced to Pydantic
|
|
27
|
+
|
|
28
|
+
# Values generated at runtime.
|
|
29
|
+
# We need to assign these some default values to get the
|
|
30
|
+
# validation working since ComputedField has not yet been
|
|
31
|
+
# introduced to Pydantic
|
|
30
32
|
reference: str
|
|
31
33
|
referencing_files: set[str]
|
|
32
|
-
redirect: Union[str,None] = None
|
|
34
|
+
redirect: Union[str, None] = None
|
|
33
35
|
status_code: int = 0
|
|
34
36
|
valid: bool = False
|
|
35
|
-
resolution_time: float = 0
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def is_link_valid(self, referencing_file:str)->bool:
|
|
37
|
+
resolution_time: float = 0
|
|
38
|
+
|
|
39
|
+
def is_link_valid(self, referencing_file: str) -> bool:
|
|
39
40
|
self.access_count += 1
|
|
40
41
|
self.referencing_files.add(referencing_file)
|
|
41
42
|
return self.valid
|
|
42
|
-
|
|
43
|
+
|
|
43
44
|
@model_validator(mode="before")
|
|
44
|
-
def check_reference(cls, data:Any)->Any:
|
|
45
|
+
def check_reference(cls, data: Any) -> Any:
|
|
45
46
|
start_time = time.time()
|
|
46
|
-
#Get out all the fields names to make them easier to reference
|
|
47
|
-
method = data[
|
|
48
|
-
reference = data[
|
|
49
|
-
timeout_seconds = data[
|
|
50
|
-
headers = data[
|
|
51
|
-
allow_redirects = data[
|
|
52
|
-
verify_ssl = data[
|
|
53
|
-
allowed_http_codes = data[
|
|
47
|
+
# Get out all the fields names to make them easier to reference
|
|
48
|
+
method = data["method"]
|
|
49
|
+
reference = data["reference"]
|
|
50
|
+
timeout_seconds = data["timeout_seconds"]
|
|
51
|
+
headers = data["headers"]
|
|
52
|
+
allow_redirects = data["allow_redirects"]
|
|
53
|
+
verify_ssl = data["verify_ssl"]
|
|
54
|
+
allowed_http_codes = data["allowed_http_codes"]
|
|
54
55
|
if not (reference.startswith("http://") or reference.startswith("https://")):
|
|
55
|
-
raise
|
|
56
|
-
|
|
56
|
+
raise (
|
|
57
|
+
ValueError(
|
|
58
|
+
f"Reference {reference} does not begin with http(s). Only http(s) references are supported"
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
|
|
57
62
|
try:
|
|
58
|
-
get = method(
|
|
59
|
-
|
|
60
|
-
|
|
63
|
+
get = method(
|
|
64
|
+
reference,
|
|
65
|
+
timeout=timeout_seconds,
|
|
66
|
+
headers=headers,
|
|
67
|
+
allow_redirects=allow_redirects,
|
|
68
|
+
verify=verify_ssl,
|
|
69
|
+
)
|
|
61
70
|
resolution_time = time.time() - start_time
|
|
62
|
-
data[
|
|
63
|
-
data[
|
|
71
|
+
data["status_code"] = get.status_code
|
|
72
|
+
data["resolution_time"] = resolution_time
|
|
64
73
|
if reference != get.url:
|
|
65
|
-
data[
|
|
74
|
+
data["redirect"] = get.url
|
|
66
75
|
else:
|
|
67
|
-
data[
|
|
76
|
+
data["redirect"] = None # None is also already the default
|
|
68
77
|
|
|
69
|
-
#Returns the updated values and sets them for the object
|
|
78
|
+
# Returns the updated values and sets them for the object
|
|
70
79
|
if get.status_code in allowed_http_codes:
|
|
71
|
-
data[
|
|
80
|
+
data["valid"] = True
|
|
72
81
|
else:
|
|
73
|
-
#print(f"Unacceptable HTTP Status Code {get.status_code} received for {reference}")
|
|
74
|
-
data[
|
|
75
|
-
return data
|
|
82
|
+
# print(f"Unacceptable HTTP Status Code {get.status_code} received for {reference}")
|
|
83
|
+
data["valid"] = False
|
|
84
|
+
return data
|
|
76
85
|
|
|
77
|
-
except Exception
|
|
86
|
+
except Exception:
|
|
78
87
|
resolution_time = time.time() - start_time
|
|
79
|
-
#print(f"Reference {reference} was not reachable after {resolution_time:.2f} seconds")
|
|
80
|
-
data[
|
|
81
|
-
data[
|
|
82
|
-
data[
|
|
83
|
-
data[
|
|
88
|
+
# print(f"Reference {reference} was not reachable after {resolution_time:.2f} seconds")
|
|
89
|
+
data["status_code"] = 0
|
|
90
|
+
data["valid"] = False
|
|
91
|
+
data["redirect"] = None
|
|
92
|
+
data["resolution_time"] = resolution_time
|
|
84
93
|
return data
|
|
85
94
|
|
|
86
95
|
|
|
87
96
|
class LinkValidator(abc.ABC):
|
|
88
|
-
cache: Union[dict[str,LinkStats], shelve.Shelf] = {}
|
|
97
|
+
cache: Union[dict[str, LinkStats], shelve.Shelf] = {}
|
|
89
98
|
uncached_checks: int = 0
|
|
90
99
|
total_checks: int = 0
|
|
91
|
-
#cache: dict[str,LinkStats] = {}
|
|
100
|
+
# cache: dict[str,LinkStats] = {}
|
|
92
101
|
|
|
93
102
|
use_file_cache: bool = False
|
|
94
|
-
reference_cache_file: str ="lookups/REFERENCE_CACHE.db"
|
|
103
|
+
reference_cache_file: str = "lookups/REFERENCE_CACHE.db"
|
|
95
104
|
|
|
96
105
|
@staticmethod
|
|
97
106
|
def initialize_cache(use_file_cache: bool = False):
|
|
@@ -99,74 +108,88 @@ class LinkValidator(abc.ABC):
|
|
|
99
108
|
if use_file_cache is False:
|
|
100
109
|
return
|
|
101
110
|
if not os.path.exists(LinkValidator.reference_cache_file):
|
|
102
|
-
print(
|
|
103
|
-
|
|
111
|
+
print(
|
|
112
|
+
f"Cache at {LinkValidator.reference_cache_file} not found - Creating it."
|
|
113
|
+
)
|
|
114
|
+
|
|
104
115
|
try:
|
|
105
|
-
LinkValidator.cache = shelve.open(
|
|
106
|
-
|
|
107
|
-
|
|
116
|
+
LinkValidator.cache = shelve.open(
|
|
117
|
+
LinkValidator.reference_cache_file, flag="c", writeback=True
|
|
118
|
+
)
|
|
119
|
+
except Exception:
|
|
120
|
+
print(
|
|
121
|
+
f"Failed to create the cache file {LinkValidator.reference_cache_file}. Reference info will not be cached."
|
|
122
|
+
)
|
|
108
123
|
LinkValidator.cache = {}
|
|
109
124
|
|
|
110
|
-
#Remove all of the failures to force those resources to be resolved again
|
|
125
|
+
# Remove all of the failures to force those resources to be resolved again
|
|
111
126
|
failed_refs = []
|
|
112
127
|
for ref in LinkValidator.cache.keys():
|
|
113
128
|
if LinkValidator.cache[ref].status_code not in ALLOWED_HTTP_CODES:
|
|
114
129
|
failed_refs.append(ref)
|
|
115
|
-
#can't remove it here because this will throw an error:
|
|
116
|
-
#cannot change size of dictionary while iterating over it
|
|
130
|
+
# can't remove it here because this will throw an error:
|
|
131
|
+
# cannot change size of dictionary while iterating over it
|
|
117
132
|
else:
|
|
118
|
-
#Set the reference count to 0 and referencing files to empty set
|
|
133
|
+
# Set the reference count to 0 and referencing files to empty set
|
|
119
134
|
LinkValidator.cache[ref].access_count = 0
|
|
120
135
|
LinkValidator.cache[ref].referencing_files = set()
|
|
121
|
-
|
|
122
|
-
for ref in failed_refs:
|
|
123
|
-
del(LinkValidator.cache[ref])
|
|
124
136
|
|
|
137
|
+
for ref in failed_refs:
|
|
138
|
+
del LinkValidator.cache[ref]
|
|
125
139
|
|
|
126
|
-
|
|
127
|
-
|
|
128
140
|
@staticmethod
|
|
129
141
|
def close_cache():
|
|
130
142
|
if LinkValidator.use_file_cache:
|
|
131
143
|
LinkValidator.cache.close()
|
|
132
144
|
|
|
133
145
|
@staticmethod
|
|
134
|
-
def validate_reference(
|
|
146
|
+
def validate_reference(
|
|
147
|
+
reference: str, referencing_file: str, raise_exception_if_failure: bool = False
|
|
148
|
+
) -> bool:
|
|
135
149
|
LinkValidator.total_checks += 1
|
|
136
150
|
if reference not in LinkValidator.cache:
|
|
137
151
|
LinkValidator.uncached_checks += 1
|
|
138
|
-
LinkValidator.cache[reference] = LinkStats(
|
|
152
|
+
LinkValidator.cache[reference] = LinkStats(
|
|
153
|
+
reference=reference, referencing_files=set([referencing_file])
|
|
154
|
+
)
|
|
139
155
|
result = LinkValidator.cache[reference].is_link_valid(referencing_file)
|
|
140
156
|
|
|
141
|
-
#print(f"Total Checks: {LinkValidator.total_checks}, Percent Cached: {100*(1 - LinkValidator.uncached_checks / LinkValidator.total_checks):.2f}")
|
|
157
|
+
# print(f"Total Checks: {LinkValidator.total_checks}, Percent Cached: {100*(1 - LinkValidator.uncached_checks / LinkValidator.total_checks):.2f}")
|
|
142
158
|
|
|
143
159
|
if result is True:
|
|
144
160
|
return True
|
|
145
161
|
elif raise_exception_if_failure is True:
|
|
146
|
-
raise(Exception(f"Reference Link Failed: {reference}"))
|
|
162
|
+
raise (Exception(f"Reference Link Failed: {reference}"))
|
|
147
163
|
else:
|
|
148
164
|
return False
|
|
165
|
+
|
|
149
166
|
@staticmethod
|
|
150
167
|
def print_link_validation_errors():
|
|
151
|
-
failures = [
|
|
168
|
+
failures = [
|
|
169
|
+
LinkValidator.cache[k]
|
|
170
|
+
for k in LinkValidator.cache
|
|
171
|
+
if LinkValidator.cache[k].valid is False
|
|
172
|
+
]
|
|
152
173
|
failures.sort(key=lambda d: d.status_code)
|
|
153
174
|
for failure in failures:
|
|
154
|
-
print(
|
|
175
|
+
print(
|
|
176
|
+
f"Link {failure.reference} invalid with HTTP Status Code [{failure.status_code}] and referenced by the following files:"
|
|
177
|
+
)
|
|
155
178
|
for ref in failure.referencing_files:
|
|
156
179
|
print(f"\t* {ref}")
|
|
157
180
|
|
|
158
181
|
@staticmethod
|
|
159
|
-
def SecurityContentObject_validate_references(v:list, values: dict)->list:
|
|
160
|
-
if
|
|
161
|
-
raise(Exception("Member 'check_references' missing from Baseline!"))
|
|
162
|
-
elif values[
|
|
163
|
-
#Reference checking is enabled
|
|
182
|
+
def SecurityContentObject_validate_references(v: list, values: dict) -> list:
|
|
183
|
+
if "check_references" not in values:
|
|
184
|
+
raise (Exception("Member 'check_references' missing from Baseline!"))
|
|
185
|
+
elif values["check_references"] is False:
|
|
186
|
+
# Reference checking is enabled
|
|
164
187
|
pass
|
|
165
|
-
elif values[
|
|
188
|
+
elif values["check_references"] is True:
|
|
166
189
|
for reference in v:
|
|
167
|
-
LinkValidator.validate_reference(reference, values[
|
|
168
|
-
#Remove the check_references key from the values dict so that it is not
|
|
169
|
-
#output by the serialization code
|
|
170
|
-
del values[
|
|
190
|
+
LinkValidator.validate_reference(reference, values["name"])
|
|
191
|
+
# Remove the check_references key from the values dict so that it is not
|
|
192
|
+
# output by the serialization code
|
|
193
|
+
del values["check_references"]
|
|
171
194
|
|
|
172
195
|
return v
|
contentctl/helper/splunk_app.py
CHANGED
|
@@ -39,6 +39,7 @@ class RetryConstant:
|
|
|
39
39
|
|
|
40
40
|
class SplunkBaseError(requests.HTTPError):
|
|
41
41
|
"""An error raise in communicating with Splunkbase"""
|
|
42
|
+
|
|
42
43
|
pass
|
|
43
44
|
|
|
44
45
|
|
|
@@ -50,6 +51,7 @@ class SplunkApp:
|
|
|
50
51
|
|
|
51
52
|
class InitializationError(Exception):
|
|
52
53
|
"""An initialization error during SplunkApp setup"""
|
|
54
|
+
|
|
53
55
|
pass
|
|
54
56
|
|
|
55
57
|
@staticmethod
|
|
@@ -68,16 +70,16 @@ class SplunkApp:
|
|
|
68
70
|
status_forcelist=status_forcelist,
|
|
69
71
|
)
|
|
70
72
|
adapter = HTTPAdapter(max_retries=retry)
|
|
71
|
-
session.mount(
|
|
72
|
-
session.mount(
|
|
73
|
+
session.mount("http://", adapter)
|
|
74
|
+
session.mount("https://", adapter)
|
|
73
75
|
return session
|
|
74
76
|
|
|
75
77
|
def __init__(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
self,
|
|
79
|
+
app_uid: Optional[int] = None,
|
|
80
|
+
app_name_id: Optional[str] = None,
|
|
81
|
+
manual_setup: bool = False,
|
|
82
|
+
) -> None:
|
|
81
83
|
if app_uid is None and app_name_id is None:
|
|
82
84
|
raise SplunkApp.InitializationError(
|
|
83
85
|
"Either app_uid (the numeric app UID e.g. 742) or app_name_id (the app name "
|
|
@@ -123,18 +125,22 @@ class SplunkApp:
|
|
|
123
125
|
if self._app_info_cache is not None:
|
|
124
126
|
return self._app_info_cache
|
|
125
127
|
elif self.app_uid is None:
|
|
126
|
-
raise SplunkApp.InitializationError(
|
|
128
|
+
raise SplunkApp.InitializationError(
|
|
129
|
+
"app_uid must be set in order to fetch app info"
|
|
130
|
+
)
|
|
127
131
|
|
|
128
132
|
# NOTE: auth not required
|
|
129
133
|
# Get app info by uid
|
|
130
134
|
try:
|
|
131
135
|
response = self.requests_retry_session().get(
|
|
132
136
|
APIEndPoint.SPLUNK_BASE_APP_INFO.format(app_uid=self.app_uid),
|
|
133
|
-
timeout=RetryConstant.RETRY_INTERVAL
|
|
137
|
+
timeout=RetryConstant.RETRY_INTERVAL,
|
|
134
138
|
)
|
|
135
139
|
response.raise_for_status()
|
|
136
140
|
except requests.exceptions.RequestException as e:
|
|
137
|
-
raise SplunkBaseError(
|
|
141
|
+
raise SplunkBaseError(
|
|
142
|
+
f"Error fetching app info for app_uid {self.app_uid}: {str(e)}"
|
|
143
|
+
)
|
|
138
144
|
|
|
139
145
|
# parse JSON and set cache
|
|
140
146
|
self._app_info_cache: dict = json.loads(response.content)
|
|
@@ -156,7 +162,9 @@ class SplunkApp:
|
|
|
156
162
|
if "appid" in app_info:
|
|
157
163
|
self.app_name_id = app_info["appid"]
|
|
158
164
|
else:
|
|
159
|
-
raise SplunkBaseError(
|
|
165
|
+
raise SplunkBaseError(
|
|
166
|
+
f"Invalid response from Splunkbase; missing key 'appid': {app_info}"
|
|
167
|
+
)
|
|
160
168
|
|
|
161
169
|
def set_app_uid(self) -> None:
|
|
162
170
|
"""
|
|
@@ -166,19 +174,25 @@ class SplunkApp:
|
|
|
166
174
|
if self.app_uid is not None:
|
|
167
175
|
return
|
|
168
176
|
elif self.app_name_id is None:
|
|
169
|
-
raise SplunkApp.InitializationError(
|
|
177
|
+
raise SplunkApp.InitializationError(
|
|
178
|
+
"app_name_id must be set in order to fetch app_uid"
|
|
179
|
+
)
|
|
170
180
|
|
|
171
181
|
# NOTE: auth not required
|
|
172
182
|
# Get app_uid by app_name_id via a redirect
|
|
173
183
|
try:
|
|
174
184
|
response = self.requests_retry_session().get(
|
|
175
|
-
APIEndPoint.SPLUNK_BASE_GET_UID_REDIRECT.format(
|
|
185
|
+
APIEndPoint.SPLUNK_BASE_GET_UID_REDIRECT.format(
|
|
186
|
+
app_name_id=self.app_name_id
|
|
187
|
+
),
|
|
176
188
|
allow_redirects=False,
|
|
177
|
-
timeout=RetryConstant.RETRY_INTERVAL
|
|
189
|
+
timeout=RetryConstant.RETRY_INTERVAL,
|
|
178
190
|
)
|
|
179
191
|
response.raise_for_status()
|
|
180
192
|
except requests.exceptions.RequestException as e:
|
|
181
|
-
raise SplunkBaseError(
|
|
193
|
+
raise SplunkBaseError(
|
|
194
|
+
f"Error fetching app_uid for app_name_id '{self.app_name_id}': {str(e)}"
|
|
195
|
+
)
|
|
182
196
|
|
|
183
197
|
# Extract the app_uid from the redirect path
|
|
184
198
|
if "Location" in response.headers:
|
|
@@ -199,7 +213,9 @@ class SplunkApp:
|
|
|
199
213
|
if "title" in app_info:
|
|
200
214
|
self.app_title = app_info["title"]
|
|
201
215
|
else:
|
|
202
|
-
raise SplunkBaseError(
|
|
216
|
+
raise SplunkBaseError(
|
|
217
|
+
f"Invalid response from Splunkbase; missing key 'title': {app_info}"
|
|
218
|
+
)
|
|
203
219
|
|
|
204
220
|
def __fetch_url_latest_version_info(self) -> str:
|
|
205
221
|
"""
|
|
@@ -209,12 +225,16 @@ class SplunkApp:
|
|
|
209
225
|
# retrieve app entries using the app_name_id
|
|
210
226
|
try:
|
|
211
227
|
response = self.requests_retry_session().get(
|
|
212
|
-
APIEndPoint.SPLUNK_BASE_FETCH_APP_BY_ENTRY_ID.format(
|
|
213
|
-
|
|
228
|
+
APIEndPoint.SPLUNK_BASE_FETCH_APP_BY_ENTRY_ID.format(
|
|
229
|
+
app_name_id=self.app_name_id
|
|
230
|
+
),
|
|
231
|
+
timeout=RetryConstant.RETRY_INTERVAL,
|
|
214
232
|
)
|
|
215
233
|
response.raise_for_status()
|
|
216
234
|
except requests.exceptions.RequestException as e:
|
|
217
|
-
raise SplunkBaseError(
|
|
235
|
+
raise SplunkBaseError(
|
|
236
|
+
f"Error fetching app entries for app_name_id '{self.app_name_id}': {str(e)}"
|
|
237
|
+
)
|
|
218
238
|
|
|
219
239
|
# parse xml
|
|
220
240
|
app_xml = xmltodict.parse(response.content)
|
|
@@ -231,7 +251,9 @@ class SplunkApp:
|
|
|
231
251
|
return entry.get("link").get("@href")
|
|
232
252
|
|
|
233
253
|
# raise if no entry was found
|
|
234
|
-
raise SplunkBaseError(
|
|
254
|
+
raise SplunkBaseError(
|
|
255
|
+
f"No app entry found with 'islatest' tag set to True: {self.app_name_id}"
|
|
256
|
+
)
|
|
235
257
|
|
|
236
258
|
def __fetch_url_latest_version_download(self, info_url: str) -> str:
|
|
237
259
|
"""
|
|
@@ -241,10 +263,14 @@ class SplunkApp:
|
|
|
241
263
|
"""
|
|
242
264
|
# fetch download info
|
|
243
265
|
try:
|
|
244
|
-
response = self.requests_retry_session().get(
|
|
266
|
+
response = self.requests_retry_session().get(
|
|
267
|
+
info_url, timeout=RetryConstant.RETRY_INTERVAL
|
|
268
|
+
)
|
|
245
269
|
response.raise_for_status()
|
|
246
270
|
except requests.exceptions.RequestException as e:
|
|
247
|
-
raise SplunkBaseError(
|
|
271
|
+
raise SplunkBaseError(
|
|
272
|
+
f"Error fetching download info for app_name_id '{self.app_name_id}': {str(e)}"
|
|
273
|
+
)
|
|
248
274
|
|
|
249
275
|
# parse XML and extract download URL
|
|
250
276
|
build_xml = xmltodict.parse(response.content)
|
|
@@ -254,14 +280,18 @@ class SplunkApp:
|
|
|
254
280
|
def set_latest_version_info(self) -> None:
|
|
255
281
|
# raise if app_name_id not set
|
|
256
282
|
if self.app_name_id is None:
|
|
257
|
-
raise SplunkApp.InitializationError(
|
|
283
|
+
raise SplunkApp.InitializationError(
|
|
284
|
+
"app_name_id must be set in order to fetch latest version info"
|
|
285
|
+
)
|
|
258
286
|
|
|
259
287
|
# fetch the info URL
|
|
260
288
|
info_url = self.__fetch_url_latest_version_info()
|
|
261
289
|
|
|
262
290
|
# parse out the version number and fetch the download URL
|
|
263
291
|
self.latest_version = info_url.split("/")[-1]
|
|
264
|
-
self.latest_version_download_url = self.__fetch_url_latest_version_download(
|
|
292
|
+
self.latest_version_download_url = self.__fetch_url_latest_version_download(
|
|
293
|
+
info_url
|
|
294
|
+
)
|
|
265
295
|
|
|
266
296
|
def __get_splunk_base_session_token(self, username: str, password: str) -> str:
|
|
267
297
|
"""
|
|
@@ -309,12 +339,12 @@ class SplunkApp:
|
|
|
309
339
|
return token_value
|
|
310
340
|
|
|
311
341
|
def download(
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
342
|
+
self,
|
|
343
|
+
out: Path,
|
|
344
|
+
username: str,
|
|
345
|
+
password: str,
|
|
346
|
+
is_dir: bool = False,
|
|
347
|
+
overwrite: bool = False,
|
|
318
348
|
) -> Path:
|
|
319
349
|
"""
|
|
320
350
|
Given an output path, download the app to the specified location
|
|
@@ -336,11 +366,7 @@ class SplunkApp:
|
|
|
336
366
|
# Get the Splunkbase session token
|
|
337
367
|
token = self.__get_splunk_base_session_token(username, password)
|
|
338
368
|
response = requests.request(
|
|
339
|
-
"GET",
|
|
340
|
-
self.latest_version_download_url,
|
|
341
|
-
cookies={
|
|
342
|
-
"sessionid": token
|
|
343
|
-
}
|
|
369
|
+
"GET", self.latest_version_download_url, cookies={"sessionid": token}
|
|
344
370
|
)
|
|
345
371
|
|
|
346
372
|
# If the provided output path was a directory we need to try and pull the filename from the
|
|
@@ -348,17 +374,21 @@ class SplunkApp:
|
|
|
348
374
|
if is_dir:
|
|
349
375
|
try:
|
|
350
376
|
# Pull 'Content-Disposition' from the headers
|
|
351
|
-
content_disposition: str = response.headers[
|
|
377
|
+
content_disposition: str = response.headers["Content-Disposition"]
|
|
352
378
|
|
|
353
379
|
# Attempt to parse the filename as a KV
|
|
354
380
|
key, value = content_disposition.strip().split("=")
|
|
355
381
|
if key != "attachment;filename":
|
|
356
|
-
raise ValueError(
|
|
382
|
+
raise ValueError(
|
|
383
|
+
f"Unexpected key in 'Content-Disposition' KV pair: {key}"
|
|
384
|
+
)
|
|
357
385
|
|
|
358
386
|
# Validate the filename is the expected .tgz file
|
|
359
387
|
filename = Path(value.strip().strip('"'))
|
|
360
388
|
if filename.suffixes != [".tgz"]:
|
|
361
|
-
raise ValueError(
|
|
389
|
+
raise ValueError(
|
|
390
|
+
f"Filename has unexpected extension(s): {filename.suffixes}"
|
|
391
|
+
)
|
|
362
392
|
out = Path(out, filename)
|
|
363
393
|
except KeyError as e:
|
|
364
394
|
raise KeyError(
|
|
@@ -371,9 +401,7 @@ class SplunkApp:
|
|
|
371
401
|
|
|
372
402
|
# Ensure the output path is not already occupied
|
|
373
403
|
if out.exists() and not overwrite:
|
|
374
|
-
msg =
|
|
375
|
-
f"File already exists at {out}, cannot download the app."
|
|
376
|
-
)
|
|
404
|
+
msg = f"File already exists at {out}, cannot download the app."
|
|
377
405
|
raise Exception(msg)
|
|
378
406
|
|
|
379
407
|
# Make any parent directories as needed
|