contentctl 4.3.3__py3-none-any.whl → 4.3.5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +0 -6
- contentctl/actions/initialize.py +28 -12
- contentctl/actions/inspect.py +189 -91
- contentctl/actions/validate.py +3 -7
- contentctl/api.py +1 -1
- contentctl/contentctl.py +3 -0
- contentctl/enrichments/attack_enrichment.py +51 -82
- contentctl/enrichments/cve_enrichment.py +2 -2
- contentctl/helper/splunk_app.py +141 -10
- contentctl/input/director.py +5 -12
- contentctl/objects/abstract_security_content_objects/detection_abstract.py +11 -8
- contentctl/objects/annotated_types.py +6 -0
- contentctl/objects/atomic.py +51 -77
- contentctl/objects/config.py +145 -22
- contentctl/objects/constants.py +4 -1
- contentctl/objects/correlation_search.py +35 -28
- contentctl/objects/detection_metadata.py +71 -0
- contentctl/objects/detection_stanza.py +79 -0
- contentctl/objects/detection_tags.py +11 -9
- contentctl/objects/enums.py +0 -2
- contentctl/objects/errors.py +187 -0
- contentctl/objects/mitre_attack_enrichment.py +2 -1
- contentctl/objects/risk_event.py +94 -76
- contentctl/objects/savedsearches_conf.py +196 -0
- contentctl/objects/story_tags.py +3 -3
- contentctl/output/conf_writer.py +4 -1
- contentctl/output/new_content_yml_output.py +4 -9
- {contentctl-4.3.3.dist-info → contentctl-4.3.5.dist-info}/METADATA +4 -4
- {contentctl-4.3.3.dist-info → contentctl-4.3.5.dist-info}/RECORD +32 -32
- contentctl/objects/ssa_detection.py +0 -157
- contentctl/objects/ssa_detection_tags.py +0 -138
- contentctl/objects/unit_test_old.py +0 -10
- contentctl/objects/unit_test_ssa.py +0 -31
- {contentctl-4.3.3.dist-info → contentctl-4.3.5.dist-info}/LICENSE.md +0 -0
- {contentctl-4.3.3.dist-info → contentctl-4.3.5.dist-info}/WHEEL +0 -0
- {contentctl-4.3.3.dist-info → contentctl-4.3.5.dist-info}/entry_points.txt +0 -0
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
|
|
2
2
|
from __future__ import annotations
|
|
3
|
-
import csv
|
|
4
|
-
import os
|
|
5
3
|
import sys
|
|
6
4
|
from attackcti import attack_client
|
|
7
5
|
import logging
|
|
8
|
-
from pydantic import BaseModel
|
|
6
|
+
from pydantic import BaseModel
|
|
9
7
|
from dataclasses import field
|
|
10
|
-
from typing import
|
|
11
|
-
from
|
|
8
|
+
from typing import Any
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from contentctl.objects.mitre_attack_enrichment import MitreAttackEnrichment, MitreTactics
|
|
12
11
|
from contentctl.objects.config import validate
|
|
12
|
+
from contentctl.objects.annotated_types import MITRE_ATTACK_ID_TYPE
|
|
13
13
|
logging.getLogger('taxii2client').setLevel(logging.CRITICAL)
|
|
14
14
|
|
|
15
15
|
|
|
@@ -20,12 +20,12 @@ class AttackEnrichment(BaseModel):
|
|
|
20
20
|
@staticmethod
|
|
21
21
|
def getAttackEnrichment(config:validate)->AttackEnrichment:
|
|
22
22
|
enrichment = AttackEnrichment(use_enrichment=config.enrichments)
|
|
23
|
-
_ = enrichment.get_attack_lookup(
|
|
23
|
+
_ = enrichment.get_attack_lookup(config.mitre_cti_repo_path, config.enrichments)
|
|
24
24
|
return enrichment
|
|
25
25
|
|
|
26
|
-
def getEnrichmentByMitreID(self, mitre_id:
|
|
26
|
+
def getEnrichmentByMitreID(self, mitre_id:MITRE_ATTACK_ID_TYPE)->MitreAttackEnrichment:
|
|
27
27
|
if not self.use_enrichment:
|
|
28
|
-
raise Exception(
|
|
28
|
+
raise Exception("Error, trying to add Mitre Enrichment, but use_enrichment was set to False")
|
|
29
29
|
|
|
30
30
|
enrichment = self.data.get(mitre_id, None)
|
|
31
31
|
if enrichment is not None:
|
|
@@ -33,71 +33,69 @@ class AttackEnrichment(BaseModel):
|
|
|
33
33
|
else:
|
|
34
34
|
raise Exception(f"Error, Unable to find Mitre Enrichment for MitreID {mitre_id}")
|
|
35
35
|
|
|
36
|
-
def addMitreIDViaGroupNames(self, technique:dict, tactics:list[str], groupNames:list[str])->None:
|
|
36
|
+
def addMitreIDViaGroupNames(self, technique:dict[str,Any], tactics:list[str], groupNames:list[str])->None:
|
|
37
37
|
technique_id = technique['technique_id']
|
|
38
38
|
technique_obj = technique['technique']
|
|
39
39
|
tactics.sort()
|
|
40
40
|
|
|
41
41
|
if technique_id in self.data:
|
|
42
42
|
raise Exception(f"Error, trying to redefine MITRE ID '{technique_id}'")
|
|
43
|
-
self.data[technique_id] = MitreAttackEnrichment(mitre_attack_id
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
43
|
+
self.data[technique_id] = MitreAttackEnrichment.model_validate({'mitre_attack_id':technique_id,
|
|
44
|
+
'mitre_attack_technique':technique_obj,
|
|
45
|
+
'mitre_attack_tactics':tactics,
|
|
46
|
+
'mitre_attack_groups':groupNames,
|
|
47
|
+
'mitre_attack_group_objects':[]})
|
|
48
48
|
|
|
49
|
-
def addMitreIDViaGroupObjects(self, technique:dict, tactics:list[
|
|
49
|
+
def addMitreIDViaGroupObjects(self, technique:dict[str,Any], tactics:list[MitreTactics], groupDicts:list[dict[str,Any]])->None:
|
|
50
50
|
technique_id = technique['technique_id']
|
|
51
51
|
technique_obj = technique['technique']
|
|
52
52
|
tactics.sort()
|
|
53
53
|
|
|
54
|
-
groupNames:list[str] = sorted([group['group'] for group in
|
|
54
|
+
groupNames:list[str] = sorted([group['group'] for group in groupDicts])
|
|
55
55
|
|
|
56
56
|
if technique_id in self.data:
|
|
57
57
|
raise Exception(f"Error, trying to redefine MITRE ID '{technique_id}'")
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
58
|
+
|
|
59
|
+
self.data[technique_id] = MitreAttackEnrichment.model_validate({'mitre_attack_id': technique_id,
|
|
60
|
+
'mitre_attack_technique': technique_obj,
|
|
61
|
+
'mitre_attack_tactics': tactics,
|
|
62
|
+
'mitre_attack_groups': groupNames,
|
|
63
|
+
'mitre_attack_group_objects': groupDicts})
|
|
63
64
|
|
|
64
65
|
|
|
65
|
-
def get_attack_lookup(self, input_path:
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
print("Getting MITRE Attack Enrichment Data. This may take some time...")
|
|
69
|
-
attack_lookup = dict()
|
|
70
|
-
file_path = os.path.join(input_path, "app_template", "lookups", "mitre_enrichment.csv")
|
|
71
|
-
|
|
72
|
-
if skip_enrichment is True:
|
|
73
|
-
print("Skipping enrichment")
|
|
66
|
+
def get_attack_lookup(self, input_path: Path, enrichments:bool = False) -> dict[str,MitreAttackEnrichment]:
|
|
67
|
+
attack_lookup:dict[str,MitreAttackEnrichment] = {}
|
|
68
|
+
if not enrichments:
|
|
74
69
|
return attack_lookup
|
|
70
|
+
|
|
75
71
|
try:
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
72
|
+
print(f"Performing MITRE Enrichment using the repository at {input_path}...",end="", flush=True)
|
|
73
|
+
# The existence of the input_path is validated during cli argument validation, but it is
|
|
74
|
+
# possible that the repo is in the wrong format. If the following directories do not
|
|
75
|
+
# exist, then attack_client will fall back to resolving via REST API. We do not
|
|
76
|
+
# want this as it is slow and error prone, so we will force an exception to
|
|
77
|
+
# be generated.
|
|
78
|
+
enterprise_path = input_path/"enterprise-attack"
|
|
79
|
+
mobile_path = input_path/"ics-attack"
|
|
80
|
+
ics_path = input_path/"mobile-attack"
|
|
81
|
+
if not (enterprise_path.is_dir() and mobile_path.is_dir() and ics_path.is_dir()):
|
|
82
|
+
raise FileNotFoundError("One or more of the following paths does not exist: "
|
|
83
|
+
f"{[str(enterprise_path),str(mobile_path),str(ics_path)]}. "
|
|
84
|
+
f"Please ensure that the {input_path} directory "
|
|
85
|
+
"has been git cloned correctly.")
|
|
86
|
+
lift = attack_client(
|
|
87
|
+
local_paths= {
|
|
88
|
+
"enterprise":str(enterprise_path),
|
|
89
|
+
"mobile":str(mobile_path),
|
|
90
|
+
"ics":str(ics_path)
|
|
91
|
+
}
|
|
92
|
+
)
|
|
82
93
|
|
|
83
|
-
print(f"\r{'Techniques'.rjust(23)}: [{0.0:3.0f}%]...", end="", flush=True)
|
|
84
94
|
all_enterprise_techniques = lift.get_enterprise_techniques(stix_format=False)
|
|
85
|
-
|
|
86
|
-
print(f"\r{'Techniques'.rjust(23)}: [{100:3.0f}%]...Done!", end="\n", flush=True)
|
|
87
|
-
|
|
88
|
-
print(f"\r{'Relationships'.rjust(23)}: [{0.0:3.0f}%]...", end="", flush=True)
|
|
89
95
|
enterprise_relationships = lift.get_enterprise_relationships(stix_format=False)
|
|
90
|
-
print(f"\r{'Relationships'.rjust(23)}: [{100:3.0f}%]...Done!", end="\n", flush=True)
|
|
91
|
-
|
|
92
|
-
print(f"\r{'Groups'.rjust(23)}: [{0:3.0f}%]...", end="", flush=True)
|
|
93
96
|
enterprise_groups = lift.get_enterprise_groups(stix_format=False)
|
|
94
|
-
print(f"\r{'Groups'.rjust(23)}: [{100:3.0f}%]...Done!", end="\n", flush=True)
|
|
95
|
-
|
|
96
97
|
|
|
97
|
-
for
|
|
98
|
-
progress_percent = ((index+1)/len(all_enterprise_techniques)) * 100
|
|
99
|
-
if (sys.stdout.isatty() and sys.stdin.isatty() and sys.stderr.isatty()):
|
|
100
|
-
print(f"\r\t{'MITRE Technique Progress'.rjust(23)}: [{progress_percent:3.0f}%]...", end="", flush=True)
|
|
98
|
+
for technique in all_enterprise_techniques:
|
|
101
99
|
apt_groups:list[dict[str,Any]] = []
|
|
102
100
|
for relationship in enterprise_relationships:
|
|
103
101
|
if (relationship['target_object'] == technique['id']) and relationship['source_object'].startswith('intrusion-set'):
|
|
@@ -114,39 +112,10 @@ class AttackEnrichment(BaseModel):
|
|
|
114
112
|
self.addMitreIDViaGroupObjects(technique, tactics, apt_groups)
|
|
115
113
|
attack_lookup[technique['technique_id']] = {'technique': technique['technique'], 'tactics': tactics, 'groups': apt_groups}
|
|
116
114
|
|
|
117
|
-
if store_csv:
|
|
118
|
-
f = open(file_path, 'w')
|
|
119
|
-
writer = csv.writer(f)
|
|
120
|
-
writer.writerow(['mitre_id', 'technique', 'tactics' ,'groups'])
|
|
121
|
-
for key in attack_lookup.keys():
|
|
122
|
-
if len(attack_lookup[key]['groups']) == 0:
|
|
123
|
-
groups = 'no'
|
|
124
|
-
else:
|
|
125
|
-
groups = '|'.join(attack_lookup[key]['groups'])
|
|
126
|
-
|
|
127
|
-
writer.writerow([
|
|
128
|
-
key,
|
|
129
|
-
attack_lookup[key]['technique'],
|
|
130
|
-
'|'.join(attack_lookup[key]['tactics']),
|
|
131
|
-
groups
|
|
132
|
-
])
|
|
133
|
-
|
|
134
|
-
f.close()
|
|
135
115
|
|
|
116
|
+
|
|
136
117
|
except Exception as err:
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
with open(file_path, mode='r') as inp:
|
|
140
|
-
reader = csv.reader(inp)
|
|
141
|
-
attack_lookup = {rows[0]:{'technique': rows[1], 'tactics': rows[2].split('|'), 'groups': rows[3].split('|')} for rows in reader}
|
|
142
|
-
attack_lookup.pop('mitre_id')
|
|
143
|
-
for key in attack_lookup.keys():
|
|
144
|
-
technique_input = {'technique_id': key , 'technique': attack_lookup[key]['technique'] }
|
|
145
|
-
tactics_input = attack_lookup[key]['tactics']
|
|
146
|
-
groups_input = attack_lookup[key]['groups']
|
|
147
|
-
self.addMitreIDViaGroupNames(technique=technique_input, tactics=tactics_input, groups=groups_input)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
118
|
+
raise Exception(f"Error getting MITRE Enrichment: {str(err)}")
|
|
119
|
+
|
|
151
120
|
print("Done!")
|
|
152
121
|
return attack_lookup
|
|
@@ -8,7 +8,7 @@ from typing import Annotated, Any, Union, TYPE_CHECKING
|
|
|
8
8
|
from pydantic import BaseModel,Field, computed_field
|
|
9
9
|
from decimal import Decimal
|
|
10
10
|
from requests.exceptions import ReadTimeout
|
|
11
|
-
|
|
11
|
+
from contentctl.objects.annotated_types import CVE_TYPE
|
|
12
12
|
if TYPE_CHECKING:
|
|
13
13
|
from contentctl.objects.config import validate
|
|
14
14
|
|
|
@@ -18,7 +18,7 @@ CVESSEARCH_API_URL = 'https://cve.circl.lu'
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class CveEnrichmentObj(BaseModel):
|
|
21
|
-
id:
|
|
21
|
+
id: CVE_TYPE
|
|
22
22
|
cvss: Annotated[Decimal, Field(ge=.1, le=10, decimal_places=1)]
|
|
23
23
|
summary: str
|
|
24
24
|
|
contentctl/helper/splunk_app.py
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import time
|
|
3
1
|
import json
|
|
2
|
+
from typing import Optional, Collection
|
|
3
|
+
from pathlib import Path
|
|
4
4
|
import xml.etree.ElementTree as ET
|
|
5
|
-
from typing import List, Tuple, Optional
|
|
6
5
|
from urllib.parse import urlencode
|
|
7
6
|
|
|
8
7
|
import requests
|
|
9
8
|
import urllib3
|
|
10
9
|
import xmltodict
|
|
11
10
|
from requests.adapters import HTTPAdapter
|
|
12
|
-
from
|
|
11
|
+
from urllib3.util.retry import Retry
|
|
13
12
|
|
|
14
13
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
15
14
|
|
|
16
15
|
MAX_RETRY = 3
|
|
17
16
|
|
|
17
|
+
|
|
18
18
|
class APIEndPoint:
|
|
19
19
|
"""
|
|
20
20
|
Class which contains Static Endpoint
|
|
@@ -27,6 +27,7 @@ class APIEndPoint:
|
|
|
27
27
|
SPLUNK_BASE_GET_UID_REDIRECT = "https://apps.splunk.com/apps/id/{app_name_id}"
|
|
28
28
|
SPLUNK_BASE_APP_INFO = "https://splunkbase.splunk.com/api/v1/app/{app_uid}"
|
|
29
29
|
|
|
30
|
+
|
|
30
31
|
class RetryConstant:
|
|
31
32
|
"""
|
|
32
33
|
Class which contains Retry Constant
|
|
@@ -53,11 +54,11 @@ class SplunkApp:
|
|
|
53
54
|
|
|
54
55
|
@staticmethod
|
|
55
56
|
def requests_retry_session(
|
|
56
|
-
retries=RetryConstant.RETRY_COUNT,
|
|
57
|
-
backoff_factor=1,
|
|
58
|
-
status_forcelist=(500, 502, 503, 504),
|
|
59
|
-
session=None,
|
|
60
|
-
):
|
|
57
|
+
retries: int = RetryConstant.RETRY_COUNT,
|
|
58
|
+
backoff_factor: int = 1,
|
|
59
|
+
status_forcelist: Collection[int] = (500, 502, 503, 504),
|
|
60
|
+
session: requests.Session | None = None,
|
|
61
|
+
) -> requests.Session:
|
|
61
62
|
session = session or requests.Session()
|
|
62
63
|
retry = Retry(
|
|
63
64
|
total=retries,
|
|
@@ -260,4 +261,134 @@ class SplunkApp:
|
|
|
260
261
|
|
|
261
262
|
# parse out the version number and fetch the download URL
|
|
262
263
|
self.latest_version = info_url.split("/")[-1]
|
|
263
|
-
self.latest_version_download_url = self.__fetch_url_latest_version_download(info_url)
|
|
264
|
+
self.latest_version_download_url = self.__fetch_url_latest_version_download(info_url)
|
|
265
|
+
|
|
266
|
+
def __get_splunk_base_session_token(self, username: str, password: str) -> str:
|
|
267
|
+
"""
|
|
268
|
+
This method will generate Splunk base session token
|
|
269
|
+
|
|
270
|
+
:param username: Splunkbase username
|
|
271
|
+
:type username: str
|
|
272
|
+
:param password: Splunkbase password
|
|
273
|
+
:type password: str
|
|
274
|
+
|
|
275
|
+
:return: Splunk base session token
|
|
276
|
+
:rtype: str
|
|
277
|
+
"""
|
|
278
|
+
# Data payload for fetch splunk base session token
|
|
279
|
+
payload = urlencode(
|
|
280
|
+
{
|
|
281
|
+
"username": username,
|
|
282
|
+
"password": password,
|
|
283
|
+
}
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
headers = {
|
|
287
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
288
|
+
"cache-control": "no-cache",
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
response = requests.request(
|
|
292
|
+
"POST",
|
|
293
|
+
APIEndPoint.SPLUNK_BASE_AUTH_URL,
|
|
294
|
+
data=payload,
|
|
295
|
+
headers=headers,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
token_value = ""
|
|
299
|
+
|
|
300
|
+
if response.status_code != 200:
|
|
301
|
+
msg = (
|
|
302
|
+
f"Error occurred while executing the rest call for splunk base authentication api,"
|
|
303
|
+
f"{response.content}"
|
|
304
|
+
)
|
|
305
|
+
raise Exception(msg)
|
|
306
|
+
else:
|
|
307
|
+
root = ET.fromstring(response.content)
|
|
308
|
+
token_value = root.find("{http://www.w3.org/2005/Atom}id").text.strip()
|
|
309
|
+
return token_value
|
|
310
|
+
|
|
311
|
+
def download(
|
|
312
|
+
self,
|
|
313
|
+
out: Path,
|
|
314
|
+
username: str,
|
|
315
|
+
password: str,
|
|
316
|
+
is_dir: bool = False,
|
|
317
|
+
overwrite: bool = False
|
|
318
|
+
) -> Path:
|
|
319
|
+
"""
|
|
320
|
+
Given an output path, download the app to the specified location
|
|
321
|
+
|
|
322
|
+
:param out: the Path to download the app to
|
|
323
|
+
:type out: :class:`pathlib.Path`
|
|
324
|
+
:param username: Splunkbase username
|
|
325
|
+
:type username: str
|
|
326
|
+
:param password: Splunkbase password
|
|
327
|
+
:type password: str
|
|
328
|
+
:param is_dir: a flag indicating whether out is directory, otherwise a file (default: False)
|
|
329
|
+
:type is_dir: bool
|
|
330
|
+
:param overwrite: a flag indicating whether we can overwrite the file at out or not
|
|
331
|
+
:type overwrite: bool
|
|
332
|
+
|
|
333
|
+
:returns path: the Path the download was written to (needed when is_dir is True)
|
|
334
|
+
:rtype: :class:`pathlib.Path`
|
|
335
|
+
"""
|
|
336
|
+
# Get the Splunkbase session token
|
|
337
|
+
token = self.__get_splunk_base_session_token(username, password)
|
|
338
|
+
response = requests.request(
|
|
339
|
+
"GET",
|
|
340
|
+
self.latest_version_download_url,
|
|
341
|
+
cookies={
|
|
342
|
+
"sessionid": token
|
|
343
|
+
}
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
# If the provided output path was a directory we need to try and pull the filename from the
|
|
347
|
+
# response headers
|
|
348
|
+
if is_dir:
|
|
349
|
+
try:
|
|
350
|
+
# Pull 'Content-Disposition' from the headers
|
|
351
|
+
content_disposition: str = response.headers['Content-Disposition']
|
|
352
|
+
|
|
353
|
+
# Attempt to parse the filename as a KV
|
|
354
|
+
key, value = content_disposition.strip().split("=")
|
|
355
|
+
if key != "attachment;filename":
|
|
356
|
+
raise ValueError(f"Unexpected key in 'Content-Disposition' KV pair: {key}")
|
|
357
|
+
|
|
358
|
+
# Validate the filename is the expected .tgz file
|
|
359
|
+
filename = Path(value.strip().strip('"'))
|
|
360
|
+
if filename.suffixes != [".tgz"]:
|
|
361
|
+
raise ValueError(f"Filename has unexpected extension(s): {filename.suffixes}")
|
|
362
|
+
out = Path(out, filename)
|
|
363
|
+
except KeyError as e:
|
|
364
|
+
raise KeyError(
|
|
365
|
+
f"Unable to properly extract 'Content-Disposition' from response headers: {e}"
|
|
366
|
+
) from e
|
|
367
|
+
except ValueError as e:
|
|
368
|
+
raise ValueError(
|
|
369
|
+
f"Unable to parse filename from 'Content-Disposition' header: {e}"
|
|
370
|
+
) from e
|
|
371
|
+
|
|
372
|
+
# Ensure the output path is not already occupied
|
|
373
|
+
if out.exists() and not overwrite:
|
|
374
|
+
msg = (
|
|
375
|
+
f"File already exists at {out}, cannot download the app."
|
|
376
|
+
)
|
|
377
|
+
raise Exception(msg)
|
|
378
|
+
|
|
379
|
+
# Make any parent directories as needed
|
|
380
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
381
|
+
|
|
382
|
+
# Check for HTTP errors
|
|
383
|
+
if response.status_code != 200:
|
|
384
|
+
msg = (
|
|
385
|
+
f"Error occurred while executing the rest call for splunk base authentication api,"
|
|
386
|
+
f"{response.content}"
|
|
387
|
+
)
|
|
388
|
+
raise Exception(msg)
|
|
389
|
+
|
|
390
|
+
# Write the app to disk
|
|
391
|
+
with open(out, "wb") as file:
|
|
392
|
+
file.write(response.content)
|
|
393
|
+
|
|
394
|
+
return out
|
contentctl/input/director.py
CHANGED
|
@@ -18,8 +18,7 @@ from contentctl.objects.playbook import Playbook
|
|
|
18
18
|
from contentctl.objects.deployment import Deployment
|
|
19
19
|
from contentctl.objects.macro import Macro
|
|
20
20
|
from contentctl.objects.lookup import Lookup
|
|
21
|
-
from contentctl.objects.
|
|
22
|
-
from contentctl.objects.atomic import AtomicTest
|
|
21
|
+
from contentctl.objects.atomic import AtomicEnrichment
|
|
23
22
|
from contentctl.objects.security_content_object import SecurityContentObject
|
|
24
23
|
from contentctl.objects.data_source import DataSource
|
|
25
24
|
from contentctl.objects.event_source import EventSource
|
|
@@ -33,17 +32,14 @@ from contentctl.objects.enums import SecurityContentType
|
|
|
33
32
|
from contentctl.objects.enums import DetectionStatus
|
|
34
33
|
from contentctl.helper.utils import Utils
|
|
35
34
|
|
|
36
|
-
from contentctl.objects.enums import SecurityContentType
|
|
37
35
|
|
|
38
|
-
from contentctl.objects.enums import DetectionStatus
|
|
39
|
-
from contentctl.helper.utils import Utils
|
|
40
36
|
|
|
41
37
|
|
|
42
38
|
@dataclass
|
|
43
39
|
class DirectorOutputDto:
|
|
44
40
|
# Atomic Tests are first because parsing them
|
|
45
41
|
# is far quicker than attack_enrichment
|
|
46
|
-
|
|
42
|
+
atomic_enrichment: AtomicEnrichment
|
|
47
43
|
attack_enrichment: AttackEnrichment
|
|
48
44
|
cve_enrichment: CveEnrichment
|
|
49
45
|
detections: list[Detection]
|
|
@@ -60,10 +56,7 @@ class DirectorOutputDto:
|
|
|
60
56
|
|
|
61
57
|
def addContentToDictMappings(self, content: SecurityContentObject):
|
|
62
58
|
content_name = content.name
|
|
63
|
-
|
|
64
|
-
# Since SSA detections may have the same name as ESCU detection,
|
|
65
|
-
# for this function we prepend 'SSA ' to the name.
|
|
66
|
-
content_name = f"SSA {content_name}"
|
|
59
|
+
|
|
67
60
|
|
|
68
61
|
if content_name in self.name_to_content_map:
|
|
69
62
|
raise ValueError(
|
|
@@ -149,10 +142,10 @@ class Director():
|
|
|
149
142
|
os.path.join(self.input_dto.path, str(contentType.name))
|
|
150
143
|
)
|
|
151
144
|
security_content_files = [
|
|
152
|
-
f for f in files
|
|
145
|
+
f for f in files
|
|
153
146
|
]
|
|
154
147
|
else:
|
|
155
|
-
raise (Exception(f"Cannot createSecurityContent for unknown product."))
|
|
148
|
+
raise (Exception(f"Cannot createSecurityContent for unknown product {contentType}."))
|
|
156
149
|
|
|
157
150
|
validation_errors = []
|
|
158
151
|
|
|
@@ -83,15 +83,13 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
83
83
|
|
|
84
84
|
|
|
85
85
|
Args:
|
|
86
|
-
value (
|
|
87
|
-
SPL or a dict, in which case it is Sigma-formatted.
|
|
86
|
+
value (str): The SPL search. It must be an SPL-formatted string.
|
|
88
87
|
info (ValidationInfo): The validation info can contain a number of different objects.
|
|
89
88
|
Today it only contains the director.
|
|
90
89
|
|
|
91
90
|
Returns:
|
|
92
|
-
|
|
93
|
-
"""
|
|
94
|
-
|
|
91
|
+
str: The search, as an SPL formatted string.
|
|
92
|
+
"""
|
|
95
93
|
|
|
96
94
|
# Otherwise, the search is SPL.
|
|
97
95
|
|
|
@@ -322,12 +320,13 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
322
320
|
@property
|
|
323
321
|
def providing_technologies(self) -> List[ProvidingTechnology]:
|
|
324
322
|
return ProvidingTechnology.getProvidingTechFromSearch(self.search)
|
|
325
|
-
|
|
326
|
-
|
|
323
|
+
|
|
324
|
+
# TODO (#247): Refactor the risk property of detection_abstract
|
|
327
325
|
@computed_field
|
|
328
326
|
@property
|
|
329
327
|
def risk(self) -> list[dict[str, Any]]:
|
|
330
328
|
risk_objects: list[dict[str, str | int]] = []
|
|
329
|
+
# TODO (#246): "User Name" type should map to a "user" risk object and not "other"
|
|
331
330
|
risk_object_user_types = {'user', 'username', 'email address'}
|
|
332
331
|
risk_object_system_types = {'device', 'endpoint', 'hostname', 'ip address'}
|
|
333
332
|
process_threat_object_types = {'process name', 'process'}
|
|
@@ -389,7 +388,11 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
389
388
|
# NOTE: we ignore the type error around self.status because we are using Pydantic's
|
|
390
389
|
# use_enum_values configuration
|
|
391
390
|
# https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
|
|
392
|
-
|
|
391
|
+
|
|
392
|
+
# NOTE: The `inspect` action is HIGHLY sensitive to the structure of the metadata line in
|
|
393
|
+
# the detection stanza in savedsearches.conf. Additive operations (e.g. a new field in the
|
|
394
|
+
# dict below) should not have any impact, but renaming or removing any of these fields will
|
|
395
|
+
# break the `inspect` action.
|
|
393
396
|
return {
|
|
394
397
|
'detection_id': str(self.id),
|
|
395
398
|
'deprecated': '1' if self.status == DetectionStatus.deprecated.value else '0', # type: ignore
|