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.
Files changed (36) hide show
  1. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +0 -6
  2. contentctl/actions/initialize.py +28 -12
  3. contentctl/actions/inspect.py +189 -91
  4. contentctl/actions/validate.py +3 -7
  5. contentctl/api.py +1 -1
  6. contentctl/contentctl.py +3 -0
  7. contentctl/enrichments/attack_enrichment.py +51 -82
  8. contentctl/enrichments/cve_enrichment.py +2 -2
  9. contentctl/helper/splunk_app.py +141 -10
  10. contentctl/input/director.py +5 -12
  11. contentctl/objects/abstract_security_content_objects/detection_abstract.py +11 -8
  12. contentctl/objects/annotated_types.py +6 -0
  13. contentctl/objects/atomic.py +51 -77
  14. contentctl/objects/config.py +145 -22
  15. contentctl/objects/constants.py +4 -1
  16. contentctl/objects/correlation_search.py +35 -28
  17. contentctl/objects/detection_metadata.py +71 -0
  18. contentctl/objects/detection_stanza.py +79 -0
  19. contentctl/objects/detection_tags.py +11 -9
  20. contentctl/objects/enums.py +0 -2
  21. contentctl/objects/errors.py +187 -0
  22. contentctl/objects/mitre_attack_enrichment.py +2 -1
  23. contentctl/objects/risk_event.py +94 -76
  24. contentctl/objects/savedsearches_conf.py +196 -0
  25. contentctl/objects/story_tags.py +3 -3
  26. contentctl/output/conf_writer.py +4 -1
  27. contentctl/output/new_content_yml_output.py +4 -9
  28. {contentctl-4.3.3.dist-info → contentctl-4.3.5.dist-info}/METADATA +4 -4
  29. {contentctl-4.3.3.dist-info → contentctl-4.3.5.dist-info}/RECORD +32 -32
  30. contentctl/objects/ssa_detection.py +0 -157
  31. contentctl/objects/ssa_detection_tags.py +0 -138
  32. contentctl/objects/unit_test_old.py +0 -10
  33. contentctl/objects/unit_test_ssa.py +0 -31
  34. {contentctl-4.3.3.dist-info → contentctl-4.3.5.dist-info}/LICENSE.md +0 -0
  35. {contentctl-4.3.3.dist-info → contentctl-4.3.5.dist-info}/WHEEL +0 -0
  36. {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, Field
6
+ from pydantic import BaseModel
9
7
  from dataclasses import field
10
- from typing import Annotated,Any
11
- from contentctl.objects.mitre_attack_enrichment import MitreAttackEnrichment
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(str(config.path))
23
+ _ = enrichment.get_attack_lookup(config.mitre_cti_repo_path, config.enrichments)
24
24
  return enrichment
25
25
 
26
- def getEnrichmentByMitreID(self, mitre_id:Annotated[str, Field(pattern=r"^T\d{4}(.\d{3})?$")])->MitreAttackEnrichment:
26
+ def getEnrichmentByMitreID(self, mitre_id:MITRE_ATTACK_ID_TYPE)->MitreAttackEnrichment:
27
27
  if not self.use_enrichment:
28
- raise Exception(f"Error, trying to add Mitre Enrichment, but use_enrichment was set to False")
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=technique_id,
44
- mitre_attack_technique=technique_obj,
45
- mitre_attack_tactics=tactics,
46
- mitre_attack_groups=groupNames,
47
- mitre_attack_group_objects=[])
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[str], groupObjects:list[dict[str,Any]])->None:
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 groupObjects])
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
- self.data[technique_id] = MitreAttackEnrichment(mitre_attack_id=technique_id,
59
- mitre_attack_technique=technique_obj,
60
- mitre_attack_tactics=tactics,
61
- mitre_attack_groups=groupNames,
62
- mitre_attack_group_objects=groupObjects)
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: str, store_csv: bool = False, force_cached_or_offline: bool = False, skip_enrichment:bool = False) -> dict:
66
- if not self.use_enrichment:
67
- return {}
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
- if force_cached_or_offline is True:
78
- raise(Exception("WARNING - Using cached MITRE Attack Enrichment. Attack Enrichment may be out of date. Only use this setting for offline environments and development purposes."))
79
- print(f"\r{'Client'.rjust(23)}: [{0:3.0f}%]...", end="", flush=True)
80
- lift = attack_client()
81
- print(f"\r{'Client'.rjust(23)}: [{100:3.0f}%]...Done!", end="\n", flush=True)
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 index, technique in enumerate(all_enterprise_techniques):
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
- print(f'\nError: {str(err)}')
138
- print('Use local copy app_template/lookups/mitre_enrichment.csv')
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: Annotated[str, r"^CVE-[1|2]\d{3}-\d+$"]
21
+ id: CVE_TYPE
22
22
  cvss: Annotated[Decimal, Field(ge=.1, le=10, decimal_places=1)]
23
23
  summary: str
24
24
 
@@ -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 requests.packages.urllib3.util.retry import Retry
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
@@ -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.ssa_detection import SSADetection
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
- atomic_tests: None | list[AtomicTest]
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
- if isinstance(content, SSADetection):
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 if not f.name.startswith("ssa___")
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 (Union[str, dict[str,Any]]): The search. It can either be a string (and should be
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
- Union[str, dict[str,Any]]: The search, either in sigma or SPL format.
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
@@ -0,0 +1,6 @@
1
+ from pydantic import Field
2
+ from typing import Annotated
3
+
4
+ CVE_TYPE = Annotated[str, Field(pattern=r"^CVE-[1|2]\d{3}-\d+$")]
5
+ MITRE_ATTACK_ID_TYPE = Annotated[str, Field(pattern=r"^T\d{4}(.\d{3})?$")]
6
+ APPID_TYPE = Annotated[str,Field(pattern="^[a-zA-Z0-9_-]+$")]