contentctl 4.3.4__py3-none-any.whl → 4.4.0__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 (63) hide show
  1. contentctl/actions/build.py +1 -0
  2. contentctl/actions/detection_testing/GitService.py +10 -10
  3. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +68 -38
  4. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +5 -1
  5. contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py +10 -8
  6. contentctl/actions/initialize.py +28 -12
  7. contentctl/actions/inspect.py +191 -91
  8. contentctl/actions/new_content.py +10 -2
  9. contentctl/actions/validate.py +3 -6
  10. contentctl/api.py +1 -1
  11. contentctl/contentctl.py +3 -0
  12. contentctl/enrichments/attack_enrichment.py +49 -81
  13. contentctl/enrichments/cve_enrichment.py +6 -7
  14. contentctl/helper/splunk_app.py +141 -10
  15. contentctl/input/director.py +19 -24
  16. contentctl/input/new_content_questions.py +9 -42
  17. contentctl/objects/abstract_security_content_objects/detection_abstract.py +155 -13
  18. contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +17 -9
  19. contentctl/objects/atomic.py +51 -77
  20. contentctl/objects/base_test_result.py +7 -7
  21. contentctl/objects/baseline.py +12 -18
  22. contentctl/objects/baseline_tags.py +2 -5
  23. contentctl/objects/config.py +154 -26
  24. contentctl/objects/constants.py +34 -1
  25. contentctl/objects/correlation_search.py +79 -114
  26. contentctl/objects/dashboard.py +100 -0
  27. contentctl/objects/deployment.py +20 -5
  28. contentctl/objects/detection_metadata.py +71 -0
  29. contentctl/objects/detection_stanza.py +79 -0
  30. contentctl/objects/detection_tags.py +28 -26
  31. contentctl/objects/drilldown.py +70 -0
  32. contentctl/objects/enums.py +26 -24
  33. contentctl/objects/errors.py +187 -0
  34. contentctl/objects/investigation.py +23 -15
  35. contentctl/objects/investigation_tags.py +4 -3
  36. contentctl/objects/lookup.py +8 -1
  37. contentctl/objects/macro.py +16 -7
  38. contentctl/objects/notable_event.py +6 -5
  39. contentctl/objects/risk_analysis_action.py +4 -4
  40. contentctl/objects/risk_event.py +8 -7
  41. contentctl/objects/savedsearches_conf.py +196 -0
  42. contentctl/objects/story.py +4 -16
  43. contentctl/objects/throttling.py +46 -0
  44. contentctl/output/conf_output.py +4 -0
  45. contentctl/output/conf_writer.py +24 -4
  46. contentctl/output/new_content_yml_output.py +4 -9
  47. contentctl/output/templates/analyticstories_detections.j2 +2 -2
  48. contentctl/output/templates/analyticstories_investigations.j2 +5 -5
  49. contentctl/output/templates/analyticstories_stories.j2 +1 -1
  50. contentctl/output/templates/savedsearches_baselines.j2 +2 -3
  51. contentctl/output/templates/savedsearches_detections.j2 +12 -7
  52. contentctl/output/templates/savedsearches_investigations.j2 +3 -4
  53. contentctl/templates/detections/endpoint/anomalous_usage_of_7zip.yml +10 -1
  54. {contentctl-4.3.4.dist-info → contentctl-4.4.0.dist-info}/METADATA +6 -5
  55. {contentctl-4.3.4.dist-info → contentctl-4.4.0.dist-info}/RECORD +58 -57
  56. {contentctl-4.3.4.dist-info → contentctl-4.4.0.dist-info}/WHEEL +1 -1
  57. contentctl/objects/ssa_detection.py +0 -157
  58. contentctl/objects/ssa_detection_tags.py +0 -138
  59. contentctl/objects/unit_test_old.py +0 -10
  60. contentctl/objects/unit_test_ssa.py +0 -31
  61. contentctl/output/templates/finding_report.j2 +0 -30
  62. {contentctl-4.3.4.dist-info → contentctl-4.4.0.dist-info}/LICENSE.md +0 -0
  63. {contentctl-4.3.4.dist-info → contentctl-4.4.0.dist-info}/entry_points.txt +0 -0
@@ -1,3 +1,7 @@
1
+ from abc import ABC, abstractmethod
2
+ from uuid import UUID
3
+
4
+
1
5
  class ValidationFailed(Exception):
2
6
  """Indicates not an error in execution, but a validation failure"""
3
7
  pass
@@ -16,3 +20,186 @@ class ServerError(IntegrationTestingError):
16
20
  class ClientError(IntegrationTestingError):
17
21
  """An error encounterd during integration testing, on the client's side (locally)"""
18
22
  pass
23
+
24
+
25
+ class MetadataValidationError(Exception, ABC):
26
+ """
27
+ Base class for any errors arising from savedsearches.conf detection metadata validation
28
+ """
29
+ # The name of the rule the error relates to
30
+ rule_name: str
31
+
32
+ @property
33
+ @abstractmethod
34
+ def long_message(self) -> str:
35
+ """
36
+ A long-form error message
37
+ :returns: a str, the message
38
+ """
39
+ raise NotImplementedError()
40
+
41
+ @property
42
+ @abstractmethod
43
+ def short_message(self) -> str:
44
+ """
45
+ A short-form error message
46
+ :returns: a str, the message
47
+ """
48
+ raise NotImplementedError()
49
+
50
+
51
+ class DetectionMissingError(MetadataValidationError):
52
+ """
53
+ An error indicating a detection in the prior build could not be found in the current build
54
+ """
55
+ def __init__(
56
+ self,
57
+ rule_name: str,
58
+ *args: object
59
+ ) -> None:
60
+ self.rule_name = rule_name
61
+ super().__init__(self.long_message, *args)
62
+
63
+ @property
64
+ def long_message(self) -> str:
65
+ """
66
+ A long-form error message
67
+ :returns: a str, the message
68
+ """
69
+ return (
70
+ f"Rule '{self.rule_name}' in previous build not found in current build; "
71
+ "detection may have been removed or renamed."
72
+ )
73
+
74
+ @property
75
+ def short_message(self) -> str:
76
+ """
77
+ A short-form error message
78
+ :returns: a str, the message
79
+ """
80
+ return (
81
+ "Detection from previous build not found in current build."
82
+ )
83
+
84
+
85
+ class DetectionIDError(MetadataValidationError):
86
+ """
87
+ An error indicating the detection ID may have changed between builds
88
+ """
89
+ # The ID from the current build
90
+ current_id: UUID
91
+
92
+ # The ID from the previous build
93
+ previous_id: UUID
94
+
95
+ def __init__(
96
+ self,
97
+ rule_name: str,
98
+ current_id: UUID,
99
+ previous_id: UUID,
100
+ *args: object
101
+ ) -> None:
102
+ self.rule_name = rule_name
103
+ self.current_id = current_id
104
+ self.previous_id = previous_id
105
+ super().__init__(self.long_message, *args)
106
+
107
+ @property
108
+ def long_message(self) -> str:
109
+ """
110
+ A long-form error message
111
+ :returns: a str, the message
112
+ """
113
+ return (
114
+ f"Rule '{self.rule_name}' has ID {self.current_id} in current build "
115
+ f"and {self.previous_id} in previous build; detection IDs and "
116
+ "names should not change for the same detection between releases."
117
+ )
118
+
119
+ @property
120
+ def short_message(self) -> str:
121
+ """
122
+ A short-form error message
123
+ :returns: a str, the message
124
+ """
125
+ return (
126
+ f"Detection ID {self.current_id} in current build does not match ID {self.previous_id} in previous build."
127
+ )
128
+
129
+
130
+ class VersioningError(MetadataValidationError, ABC):
131
+ """
132
+ A base class for any metadata validation errors relating to detection versioning
133
+ """
134
+ # The version in the current build
135
+ current_version: int
136
+
137
+ # The version in the previous build
138
+ previous_version: int
139
+
140
+ def __init__(
141
+ self,
142
+ rule_name: str,
143
+ current_version: int,
144
+ previous_version: int,
145
+ *args: object
146
+ ) -> None:
147
+ self.rule_name = rule_name
148
+ self.current_version = current_version
149
+ self.previous_version = previous_version
150
+ super().__init__(self.long_message, *args)
151
+
152
+
153
+ class VersionDecrementedError(VersioningError):
154
+ """
155
+ An error indicating the version number went down between builds
156
+ """
157
+ @property
158
+ def long_message(self) -> str:
159
+ """
160
+ A long-form error message
161
+ :returns: a str, the message
162
+ """
163
+ return (
164
+ f"Rule '{self.rule_name}' has version {self.current_version} in "
165
+ f"current build and {self.previous_version} in previous build; "
166
+ "detection versions cannot decrease in successive builds."
167
+ )
168
+
169
+ @property
170
+ def short_message(self) -> str:
171
+ """
172
+ A short-form error message
173
+ :returns: a str, the message
174
+ """
175
+ return (
176
+ f"Detection version ({self.current_version}) in current build is less than version "
177
+ f"({self.previous_version}) in previous build."
178
+ )
179
+
180
+
181
+ class VersionBumpingError(VersioningError):
182
+ """
183
+ An error indicating the detection changed but its version wasn't bumped appropriately
184
+ """
185
+ @property
186
+ def long_message(self) -> str:
187
+ """
188
+ A long-form error message
189
+ :returns: a str, the message
190
+ """
191
+ return (
192
+ f"Rule '{self.rule_name}' has changed in current build compared to previous "
193
+ "build (stanza hashes differ); the detection version should be bumped "
194
+ f"to at least {self.previous_version + 1}."
195
+ )
196
+
197
+ @property
198
+ def short_message(self) -> str:
199
+ """
200
+ A short-form error message
201
+ :returns: a str, the message
202
+ """
203
+ return (
204
+ f"Detection version in current build should be bumped to at least {self.previous_version + 1}."
205
+ )
@@ -1,20 +1,23 @@
1
1
  from __future__ import annotations
2
2
  import re
3
- from typing import TYPE_CHECKING, Optional, List, Any
4
- from pydantic import field_validator, computed_field, Field, ValidationInfo, ConfigDict,model_serializer
5
- if TYPE_CHECKING:
6
- from contentctl.input.director import DirectorOutputDto
3
+ from typing import List, Any
4
+ from pydantic import computed_field, Field, ConfigDict,model_serializer
7
5
  from contentctl.objects.security_content_object import SecurityContentObject
8
6
  from contentctl.objects.enums import DataModel
9
7
  from contentctl.objects.investigation_tags import InvestigationTags
10
-
8
+ from contentctl.objects.constants import (
9
+ CONTENTCTL_MAX_SEARCH_NAME_LENGTH,
10
+ CONTENTCTL_RESPONSE_TASK_NAME_FORMAT_TEMPLATE,
11
+ CONTENTCTL_MAX_STANZA_LENGTH
12
+ )
13
+ from contentctl.objects.config import CustomApp
11
14
 
12
15
  # TODO (#266): disable the use_enum_values configuration
13
16
  class Investigation(SecurityContentObject):
14
17
  model_config = ConfigDict(use_enum_values=True,validate_default=False)
15
18
  type: str = Field(...,pattern="^Investigation$")
16
19
  datamodel: list[DataModel] = Field(...)
17
-
20
+ name:str = Field(...,max_length=CONTENTCTL_MAX_SEARCH_NAME_LENGTH)
18
21
  search: str = Field(...)
19
22
  how_to_implement: str = Field(...)
20
23
  known_false_positives: str = Field(...)
@@ -27,11 +30,11 @@ class Investigation(SecurityContentObject):
27
30
  @property
28
31
  def inputs(self)->List[str]:
29
32
  #Parse out and return all inputs from the searchj
30
- inputs = []
33
+ inputs:List[str] = []
31
34
  pattern = r"\$([^\s.]*)\$"
32
35
 
33
36
  for input in re.findall(pattern, self.search):
34
- inputs.append(input)
37
+ inputs.append(str(input))
35
38
 
36
39
  return inputs
37
40
 
@@ -40,6 +43,16 @@ class Investigation(SecurityContentObject):
40
43
  def lowercase_name(self)->str:
41
44
  return self.name.replace(' ', '_').replace('-','_').replace('.','_').replace('/','_').lower().replace(' ', '_').replace('-','_').replace('.','_').replace('/','_').lower()
42
45
 
46
+
47
+ # This is a slightly modified version of the get_conf_stanza_name function from
48
+ # SecurityContentObject_Abstract
49
+ def get_response_task_name(self, app:CustomApp, max_stanza_length:int=CONTENTCTL_MAX_STANZA_LENGTH)->str:
50
+ stanza_name = CONTENTCTL_RESPONSE_TASK_NAME_FORMAT_TEMPLATE.format(app_label=app.label, detection_name=self.name)
51
+ if len(stanza_name) > max_stanza_length:
52
+ raise ValueError(f"conf stanza may only be {max_stanza_length} characters, "
53
+ f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' ")
54
+ return stanza_name
55
+
43
56
 
44
57
  @model_serializer
45
58
  def serialize_model(self):
@@ -66,12 +79,7 @@ class Investigation(SecurityContentObject):
66
79
 
67
80
 
68
81
  def model_post_init(self, ctx:dict[str,Any]):
69
- # director: Optional[DirectorOutputDto] = ctx.get("output_dto",None)
70
- # if not isinstance(director,DirectorOutputDto):
71
- # raise ValueError("DirectorOutputDto was not passed in context of Detection model_post_init")
72
- director: Optional[DirectorOutputDto] = ctx.get("output_dto",None)
82
+ # Ensure we link all stories this investigation references
83
+ # back to itself
73
84
  for story in self.tags.analytic_story:
74
85
  story.investigations.append(self)
75
-
76
-
77
-
@@ -1,12 +1,13 @@
1
1
  from __future__ import annotations
2
+ from typing import List
2
3
  from pydantic import BaseModel, Field, field_validator, ValidationInfo, model_serializer
3
4
  from contentctl.objects.story import Story
4
5
  from contentctl.objects.enums import SecurityContentInvestigationProductName, SecurityDomain
5
6
 
6
7
  class InvestigationTags(BaseModel):
7
- analytic_story: list[Story] = Field([],min_length=1)
8
- product: list[SecurityContentInvestigationProductName] = Field(...,min_length=1)
9
- required_fields: list[str] = Field(min_length=1)
8
+ analytic_story: List[Story] = Field([],min_length=1)
9
+ product: List[SecurityContentInvestigationProductName] = Field(...,min_length=1)
10
+ required_fields: List[str] = Field(min_length=1)
10
11
  security_domain: SecurityDomain = Field(...)
11
12
 
12
13
 
@@ -1,8 +1,10 @@
1
1
  from __future__ import annotations
2
- from pydantic import field_validator, ValidationInfo, model_validator, FilePath, model_serializer
2
+ from pydantic import field_validator, ValidationInfo, model_validator, FilePath, model_serializer, Field, NonNegativeInt
3
3
  from typing import TYPE_CHECKING, Optional, Any, Union
4
4
  import re
5
5
  import csv
6
+ import uuid
7
+ import datetime
6
8
  if TYPE_CHECKING:
7
9
  from contentctl.input.director import DirectorOutputDto
8
10
  from contentctl.objects.config import validate
@@ -32,6 +34,11 @@ class Lookup(SecurityContentObject):
32
34
  match_type: Optional[str] = None
33
35
  min_matches: Optional[int] = None
34
36
  case_sensitive_match: Optional[bool] = None
37
+ # TODO: Add id field to all lookup ymls
38
+ id: uuid.UUID = Field(default_factory=uuid.uuid4)
39
+ date: datetime.date = Field(datetime.date.today())
40
+ author: str = Field("NO AUTHOR DEFINED",max_length=255)
41
+ version: NonNegativeInt = 1
35
42
 
36
43
 
37
44
  @model_serializer
@@ -3,12 +3,13 @@
3
3
  from __future__ import annotations
4
4
  from typing import TYPE_CHECKING, List
5
5
  import re
6
- from pydantic import Field, model_serializer
6
+ from pydantic import Field, model_serializer, NonNegativeInt
7
+ import uuid
8
+ import datetime
7
9
  if TYPE_CHECKING:
8
10
  from contentctl.input.director import DirectorOutputDto
9
11
  from contentctl.objects.security_content_object import SecurityContentObject
10
12
 
11
-
12
13
  #The following macros are included in commonly-installed apps.
13
14
  #As such, we will ignore if they are missing from our app.
14
15
  #Included in
@@ -22,7 +23,11 @@ MACROS_TO_IGNORE.add("cim_corporate_web_domain_search") #Part of CIM/Splunk_SA_C
22
23
  class Macro(SecurityContentObject):
23
24
  definition: str = Field(..., min_length=1)
24
25
  arguments: List[str] = Field([])
25
-
26
+ # TODO: Add id field to all macro ymls
27
+ id: uuid.UUID = Field(default_factory=uuid.uuid4)
28
+ date: datetime.date = Field(datetime.date.today())
29
+ author: str = Field("NO AUTHOR DEFINED",max_length=255)
30
+ version: NonNegativeInt = 1
26
31
 
27
32
 
28
33
 
@@ -49,10 +54,15 @@ class Macro(SecurityContentObject):
49
54
  #If a comment ENDS in a macro, for example ```this is a comment with a macro `macro_here````
50
55
  #then there is a small edge case where the regex below does not work properly. If that is
51
56
  #the case, we edit the search slightly to insert a space
52
- text_field = re.sub(r"\`\`\`\`", r"` ```", text_field)
53
- text_field = re.sub(r"\`\`\`.*?\`\`\`", " ", text_field)
54
-
57
+ if re.findall(r"\`\`\`\`", text_field):
58
+ raise ValueError("Search contained four or more '`' characters in a row which is invalid SPL"
59
+ "This may have occurred when a macro was commented out.\n"
60
+ "Please ammend your search to remove the substring '````'")
55
61
 
62
+ # replace all the macros with a space
63
+ text_field = re.sub(r"\`\`\`[\s\S]*?\`\`\`", " ", text_field)
64
+
65
+
56
66
  macros_to_get = re.findall(r'`([^\s]+)`', text_field)
57
67
  #If macros take arguments, stop at the first argument. We just want the name of the macro
58
68
  macros_to_get = set([macro[:macro.find('(')] if macro.find('(') != -1 else macro for macro in macros_to_get])
@@ -62,4 +72,3 @@ class Macro(SecurityContentObject):
62
72
  macros_to_get -= macros_to_ignore
63
73
  return Macro.mapNamesToSecurityContentObjects(list(macros_to_get), director)
64
74
 
65
-
@@ -1,4 +1,4 @@
1
- from pydantic import BaseModel
1
+ from pydantic import ConfigDict, BaseModel
2
2
 
3
3
  from contentctl.objects.detection import Detection
4
4
 
@@ -11,10 +11,11 @@ class NotableEvent(BaseModel):
11
11
  # The search ID that found that generated this risk event
12
12
  orig_sid: str
13
13
 
14
- class Config:
15
- # Allowing fields that aren't explicitly defined to be passed since some of the risk event's
16
- # fields vary depending on the SPL which generated them
17
- extra = 'allow'
14
+ # Allowing fields that aren't explicitly defined to be passed since some of the risk event's
15
+ # fields vary depending on the SPL which generated them
16
+ model_config = ConfigDict(
17
+ extra='allow'
18
+ )
18
19
 
19
20
  def validate_against_detection(self, detection: Detection) -> None:
20
21
  raise NotImplementedError()
@@ -1,7 +1,7 @@
1
1
  from typing import Any
2
2
  import json
3
3
 
4
- from pydantic import BaseModel, validator
4
+ from pydantic import BaseModel, field_validator
5
5
 
6
6
  from contentctl.objects.risk_object import RiskObject
7
7
  from contentctl.objects.threat_object import ThreatObject
@@ -21,11 +21,11 @@ class RiskAnalysisAction(BaseModel):
21
21
  risk_objects: list[RiskObject]
22
22
  message: str
23
23
 
24
- @validator("message", always=True, pre=True)
24
+ @field_validator("message", mode="before")
25
25
  @classmethod
26
- def _validate_message(cls, v, values) -> str:
26
+ def _validate_message(cls, v: Any) -> str:
27
27
  """
28
- Validate splunk_path and derive if None
28
+ Validate message and derive if None
29
29
  """
30
30
  if v is None:
31
31
  raise ValueError(
@@ -1,7 +1,7 @@
1
1
  import re
2
+ from functools import cached_property
2
3
 
3
- from pydantic import BaseModel, Field, PrivateAttr, field_validator, computed_field
4
-
4
+ from pydantic import ConfigDict, BaseModel, Field, PrivateAttr, field_validator, computed_field
5
5
  from contentctl.objects.errors import ValidationFailed
6
6
  from contentctl.objects.detection import Detection
7
7
  from contentctl.objects.observable import Observable
@@ -85,10 +85,11 @@ class RiskEvent(BaseModel):
85
85
  # Private attribute caching the observable this RiskEvent is mapped to
86
86
  _matched_observable: Observable | None = PrivateAttr(default=None)
87
87
 
88
- class Config:
89
- # Allowing fields that aren't explicitly defined to be passed since some of the risk event's
90
- # fields vary depending on the SPL which generated them
91
- extra = "allow"
88
+ # Allowing fields that aren't explicitly defined to be passed since some of the risk event's
89
+ # fields vary depending on the SPL which generated them
90
+ model_config = ConfigDict(
91
+ extra="allow"
92
+ )
92
93
 
93
94
  @field_validator("annotations_mitre_attack", "analyticstories", mode="before")
94
95
  @classmethod
@@ -103,7 +104,7 @@ class RiskEvent(BaseModel):
103
104
  return [v]
104
105
 
105
106
  @computed_field
106
- @property
107
+ @cached_property
107
108
  def source_field_name(self) -> str:
108
109
  """
109
110
  A cached derivation of the source field name the risk event corresponds to in the relevant
@@ -0,0 +1,196 @@
1
+
2
+ from pathlib import Path
3
+ from typing import Any, ClassVar
4
+ import re
5
+ import tempfile
6
+ import tarfile
7
+
8
+ from pydantic import BaseModel, Field, PrivateAttr
9
+
10
+ from contentctl.objects.detection_stanza import DetectionStanza
11
+
12
+
13
+ class SavedsearchesConf(BaseModel):
14
+ """
15
+ A model of the savedsearches.conf file, represented as a set of stanzas
16
+
17
+ NOTE: At present, this model only parses the detections themselves from the .conf; thing like
18
+ baselines or response tasks are left alone currently
19
+ """
20
+ # The path to the conf file
21
+ path: Path = Field(...)
22
+
23
+ # The app label (used for pattern matching in the conf) (e.g. ESCU)
24
+ app_label: str = Field(...)
25
+
26
+ # A dictionary mapping rule names to a model of the corresponding stanza in the conf
27
+ detection_stanzas: dict[str, DetectionStanza] = Field(default={}, init=False)
28
+
29
+ # A internal flag indicating whether we are currently in the detections portion of the conf
30
+ # during parsing
31
+ _in_detections: bool = PrivateAttr(default=False)
32
+
33
+ # A internal flag indicating whether we are currently in a specific section of the conf
34
+ # during parsing
35
+ _in_section: bool = PrivateAttr(default=False)
36
+
37
+ # A running list of the accumulated lines identified as part of the current section
38
+ _current_section_lines: list[str] = PrivateAttr(default=[])
39
+
40
+ # The name of the current section
41
+ _current_section_name: str | None = PrivateAttr(default=None)
42
+
43
+ # The current line number as we continue to parse the file
44
+ _current_line_no: int = PrivateAttr(default=0)
45
+
46
+ # A format string for the path to the savedsearches.conf in the app package
47
+ PACKAGE_CONF_PATH_FMT_STR: ClassVar[str] = "{appid}/default/savedsearches.conf"
48
+
49
+ def model_post_init(self, __context: Any) -> None:
50
+ super().model_post_init(__context)
51
+ self._parse_detection_stanzas()
52
+
53
+ def is_section_header(self, line: str) -> bool:
54
+ """
55
+ Given a line, determine if the line is a section header, indicating the start of a new
56
+ section
57
+
58
+ :param line: a line from the conf file
59
+ :type line: str
60
+
61
+ :returns: a bool indicating whether the current line is a section header or not
62
+ :rtype: bool
63
+ """
64
+ # Compile the pattern based on the app name
65
+ pattern = re.compile(r"\[" + self.app_label + r" - .+ - Rule\]")
66
+ if pattern.match(line):
67
+ return True
68
+ return False
69
+
70
+ def section_start(self, line: str) -> None:
71
+ """
72
+ Given a line, adjust the state to track a new section
73
+
74
+ :param line: a line from the conf file
75
+ :type line: str
76
+ """
77
+ # Determine the new section name:
78
+ new_section_name = line.strip().strip("[").strip("]")
79
+
80
+ # Raise if we are in a section already according to the state (we cannot statr a new section
81
+ # before ending the previous section)
82
+ if self._in_section:
83
+ raise Exception(
84
+ "Attempting to start a new section w/o ending the current one; check for "
85
+ f"parsing/serialization errors: (current section: '{self._current_section_name}', "
86
+ f"new section: '{new_section_name}') [see line {self._current_line_no} in "
87
+ f"{self.path}]"
88
+ )
89
+
90
+ # Capture the name of this section, reset the lines, and indicate that we are now in a
91
+ # section
92
+ self._current_section_name = new_section_name
93
+ self._current_section_lines = [line]
94
+ self._in_section = True
95
+
96
+ def section_end(self) -> None:
97
+ """
98
+ Adjust the state end the section we were enumerating; parse the lines as a DetectionStanza
99
+ """
100
+ # Name should have been set during section start
101
+ if self._current_section_name is None:
102
+ raise Exception(
103
+ "Name for the current section was never set; check for parsing/serialization "
104
+ f"errors [see line {self._current_line_no} in {self.path}]."
105
+ )
106
+ elif self._current_section_name in self.detection_stanzas:
107
+ # Each stanza should be unique, so the name should not already be in the dict
108
+ raise Exception(
109
+ f"Name '{self._current_section_name}' already in set of stanzas [see line "
110
+ f"{self._current_line_no} in {self.path}]."
111
+ )
112
+
113
+ # Build the stanza model from the accumulated lines and adjust the state to end this section
114
+ self.detection_stanzas[self._current_section_name] = DetectionStanza(
115
+ name=self._current_section_name,
116
+ lines=self._current_section_lines
117
+ )
118
+ self._in_section = False
119
+
120
+ def _parse_detection_stanzas(self) -> None:
121
+ """
122
+ Open the conf file, and parse out DetectionStanza objects from the raw conf stanzas
123
+ """
124
+ # We don't want to parse the stanzas twice (non-atomic operation)
125
+ if len(self.detection_stanzas) != 0:
126
+ raise Exception(
127
+ f"{len(self.detection_stanzas)} stanzas have already been parsed from this conf; we"
128
+ " do not need to parse them again"
129
+ )
130
+
131
+ # Open the conf file and iterate over the lines
132
+ with open(self.path, "r") as file:
133
+ for line in file:
134
+ self._current_line_no += 1
135
+
136
+ # Break when we get to the end of the app detections
137
+ if line.strip() == f"### END {self.app_label} DETECTIONS ###":
138
+ break
139
+ elif self._in_detections:
140
+ # Check if we are in the detections portion of the conf, and then if we are in a
141
+ # section
142
+ if self._in_section:
143
+ # If we are w/in a section and have hit an empty line, close the section
144
+ if line.strip() == "":
145
+ self.section_end()
146
+ elif self.is_section_header(line):
147
+ # Raise if we encounter a section header w/in a section
148
+ raise Exception(
149
+ "Encountered section header while already in section (current "
150
+ f"section: '{self._current_section_name}') [see line "
151
+ f"{self._current_line_no} in {self.path}]."
152
+ )
153
+ else:
154
+ # Otherwise, append the line
155
+ self._current_section_lines.append(line)
156
+ elif self.is_section_header(line):
157
+ # If we encounter a section header while not already in a section, start a
158
+ # new one
159
+ self.section_start(line)
160
+ elif line.strip() != "":
161
+ # If we are not in a section and have encountered anything other than an
162
+ # empty line, something is wrong
163
+ raise Exception(
164
+ "Found a non-empty line outside a stanza [see line "
165
+ f"{self._current_line_no} in {self.path}]."
166
+ )
167
+ elif line.strip() == f"### {self.app_label} DETECTIONS ###":
168
+ # We have hit the detections portion of the conf and we adjust the state
169
+ # accordingly
170
+ self._in_detections = True
171
+
172
+ @staticmethod
173
+ def init_from_package(package_path: Path, app_name: str, appid: str) -> "SavedsearchesConf":
174
+ """
175
+ Alternate constructor which can take an app package, and extract the savedsearches.conf from
176
+ a temporary file.
177
+
178
+ :param package_path: Path to the app package
179
+ :type package_path: :class:`pathlib.Path`
180
+ :param app_name: the name of the app (e.g. ESCU)
181
+ :type app_name: str
182
+
183
+ :returns: a SavedsearchesConf object
184
+ :rtype: :class:`contentctl.objects.savedsearches_conf.SavedsearchesConf`
185
+ """
186
+ # Create a temporary directory
187
+ with tempfile.TemporaryDirectory() as tmpdir:
188
+ # Open the tar/gzip archive
189
+ with tarfile.open(package_path) as package:
190
+ # Extract the savedsearches.conf and use it to init the model
191
+ package_conf_path = SavedsearchesConf.PACKAGE_CONF_PATH_FMT_STR.format(appid=appid)
192
+ package.extract(package_conf_path, path=tmpdir)
193
+ return SavedsearchesConf(
194
+ path=Path(tmpdir, package_conf_path),
195
+ app_label=app_name
196
+ )
@@ -8,26 +8,14 @@ if TYPE_CHECKING:
8
8
  from contentctl.objects.investigation import Investigation
9
9
  from contentctl.objects.baseline import Baseline
10
10
  from contentctl.objects.data_source import DataSource
11
+ from contentctl.objects.config import CustomApp
11
12
 
12
13
  from contentctl.objects.security_content_object import SecurityContentObject
13
14
 
14
-
15
-
16
-
17
-
18
- #from contentctl.objects.investigation import Investigation
19
-
20
-
21
-
22
15
  class Story(SecurityContentObject):
23
16
  narrative: str = Field(...)
24
17
  tags: StoryTags = Field(...)
25
18
 
26
- # enrichments
27
- #detection_names: List[str] = []
28
- #investigation_names: List[str] = []
29
- #baseline_names: List[str] = []
30
-
31
19
  # These are updated when detection and investigation objects are created.
32
20
  # Specifically in the model_post_init functions
33
21
  detections:List[Detection] = []
@@ -46,9 +34,9 @@ class Story(SecurityContentObject):
46
34
  return sorted(list(data_source_objects))
47
35
 
48
36
 
49
- def storyAndInvestigationNamesWithApp(self, app_name:str)->List[str]:
50
- return [f"{app_name} - {name} - Rule" for name in self.detection_names] + \
51
- [f"{app_name} - {name} - Response Task" for name in self.investigation_names]
37
+ def storyAndInvestigationNamesWithApp(self, app:CustomApp)->List[str]:
38
+ return [detection.get_conf_stanza_name(app) for detection in self.detections] + \
39
+ [investigation.get_response_task_name(app) for investigation in self.investigations]
52
40
 
53
41
  @model_serializer
54
42
  def serialize_model(self):