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.
- contentctl/actions/build.py +1 -0
- contentctl/actions/detection_testing/GitService.py +10 -10
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +68 -38
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +5 -1
- contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py +10 -8
- contentctl/actions/initialize.py +28 -12
- contentctl/actions/inspect.py +191 -91
- contentctl/actions/new_content.py +10 -2
- contentctl/actions/validate.py +3 -6
- contentctl/api.py +1 -1
- contentctl/contentctl.py +3 -0
- contentctl/enrichments/attack_enrichment.py +49 -81
- contentctl/enrichments/cve_enrichment.py +6 -7
- contentctl/helper/splunk_app.py +141 -10
- contentctl/input/director.py +19 -24
- contentctl/input/new_content_questions.py +9 -42
- contentctl/objects/abstract_security_content_objects/detection_abstract.py +155 -13
- contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +17 -9
- contentctl/objects/atomic.py +51 -77
- contentctl/objects/base_test_result.py +7 -7
- contentctl/objects/baseline.py +12 -18
- contentctl/objects/baseline_tags.py +2 -5
- contentctl/objects/config.py +154 -26
- contentctl/objects/constants.py +34 -1
- contentctl/objects/correlation_search.py +79 -114
- contentctl/objects/dashboard.py +100 -0
- contentctl/objects/deployment.py +20 -5
- contentctl/objects/detection_metadata.py +71 -0
- contentctl/objects/detection_stanza.py +79 -0
- contentctl/objects/detection_tags.py +28 -26
- contentctl/objects/drilldown.py +70 -0
- contentctl/objects/enums.py +26 -24
- contentctl/objects/errors.py +187 -0
- contentctl/objects/investigation.py +23 -15
- contentctl/objects/investigation_tags.py +4 -3
- contentctl/objects/lookup.py +8 -1
- contentctl/objects/macro.py +16 -7
- contentctl/objects/notable_event.py +6 -5
- contentctl/objects/risk_analysis_action.py +4 -4
- contentctl/objects/risk_event.py +8 -7
- contentctl/objects/savedsearches_conf.py +196 -0
- contentctl/objects/story.py +4 -16
- contentctl/objects/throttling.py +46 -0
- contentctl/output/conf_output.py +4 -0
- contentctl/output/conf_writer.py +24 -4
- contentctl/output/new_content_yml_output.py +4 -9
- contentctl/output/templates/analyticstories_detections.j2 +2 -2
- contentctl/output/templates/analyticstories_investigations.j2 +5 -5
- contentctl/output/templates/analyticstories_stories.j2 +1 -1
- contentctl/output/templates/savedsearches_baselines.j2 +2 -3
- contentctl/output/templates/savedsearches_detections.j2 +12 -7
- contentctl/output/templates/savedsearches_investigations.j2 +3 -4
- contentctl/templates/detections/endpoint/anomalous_usage_of_7zip.yml +10 -1
- {contentctl-4.3.4.dist-info → contentctl-4.4.0.dist-info}/METADATA +6 -5
- {contentctl-4.3.4.dist-info → contentctl-4.4.0.dist-info}/RECORD +58 -57
- {contentctl-4.3.4.dist-info → contentctl-4.4.0.dist-info}/WHEEL +1 -1
- 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/output/templates/finding_report.j2 +0 -30
- {contentctl-4.3.4.dist-info → contentctl-4.4.0.dist-info}/LICENSE.md +0 -0
- {contentctl-4.3.4.dist-info → contentctl-4.4.0.dist-info}/entry_points.txt +0 -0
contentctl/objects/errors.py
CHANGED
|
@@ -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
|
|
4
|
-
from pydantic import
|
|
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
|
-
#
|
|
70
|
-
#
|
|
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:
|
|
8
|
-
product:
|
|
9
|
-
required_fields:
|
|
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
|
|
contentctl/objects/lookup.py
CHANGED
|
@@ -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
|
contentctl/objects/macro.py
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
extra
|
|
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,
|
|
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
|
-
@
|
|
24
|
+
@field_validator("message", mode="before")
|
|
25
25
|
@classmethod
|
|
26
|
-
def _validate_message(cls, v
|
|
26
|
+
def _validate_message(cls, v: Any) -> str:
|
|
27
27
|
"""
|
|
28
|
-
Validate
|
|
28
|
+
Validate message and derive if None
|
|
29
29
|
"""
|
|
30
30
|
if v is None:
|
|
31
31
|
raise ValueError(
|
contentctl/objects/risk_event.py
CHANGED
|
@@ -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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
extra
|
|
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
|
-
@
|
|
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
|
+
)
|
contentctl/objects/story.py
CHANGED
|
@@ -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,
|
|
50
|
-
return [
|
|
51
|
-
[
|
|
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):
|