contentctl 5.0.0a0__py3-none-any.whl → 5.0.0a3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- contentctl/__init__.py +1 -1
- contentctl/actions/build.py +88 -55
- contentctl/actions/deploy_acs.py +29 -24
- contentctl/actions/detection_testing/DetectionTestingManager.py +66 -41
- contentctl/actions/detection_testing/GitService.py +134 -76
- contentctl/actions/detection_testing/generate_detection_coverage_badge.py +48 -30
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +163 -124
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +45 -32
- contentctl/actions/detection_testing/progress_bar.py +3 -0
- contentctl/actions/detection_testing/views/DetectionTestingView.py +15 -18
- contentctl/actions/detection_testing/views/DetectionTestingViewCLI.py +1 -5
- contentctl/actions/detection_testing/views/DetectionTestingViewFile.py +2 -2
- contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py +1 -4
- contentctl/actions/doc_gen.py +9 -5
- contentctl/actions/initialize.py +45 -33
- contentctl/actions/inspect.py +118 -61
- contentctl/actions/new_content.py +78 -50
- contentctl/actions/release_notes.py +276 -146
- contentctl/actions/reporting.py +23 -19
- contentctl/actions/test.py +31 -25
- contentctl/actions/validate.py +54 -34
- contentctl/api.py +54 -45
- contentctl/contentctl.py +12 -13
- contentctl/enrichments/attack_enrichment.py +112 -72
- contentctl/enrichments/cve_enrichment.py +34 -28
- contentctl/enrichments/splunk_app_enrichment.py +38 -36
- contentctl/helper/link_validator.py +101 -78
- contentctl/helper/splunk_app.py +69 -41
- contentctl/helper/utils.py +58 -39
- contentctl/input/director.py +69 -37
- contentctl/input/new_content_questions.py +26 -34
- contentctl/input/yml_reader.py +22 -17
- contentctl/objects/abstract_security_content_objects/detection_abstract.py +250 -314
- contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +58 -36
- contentctl/objects/alert_action.py +8 -8
- contentctl/objects/annotated_types.py +1 -1
- contentctl/objects/atomic.py +64 -54
- contentctl/objects/base_test.py +2 -1
- contentctl/objects/base_test_result.py +16 -8
- contentctl/objects/baseline.py +41 -30
- contentctl/objects/baseline_tags.py +29 -22
- contentctl/objects/config.py +772 -560
- contentctl/objects/constants.py +29 -58
- contentctl/objects/correlation_search.py +75 -55
- contentctl/objects/dashboard.py +55 -41
- contentctl/objects/data_source.py +13 -13
- contentctl/objects/deployment.py +44 -37
- contentctl/objects/deployment_email.py +1 -1
- contentctl/objects/deployment_notable.py +2 -1
- contentctl/objects/deployment_phantom.py +5 -5
- contentctl/objects/deployment_rba.py +1 -1
- contentctl/objects/deployment_scheduling.py +1 -1
- contentctl/objects/deployment_slack.py +1 -1
- contentctl/objects/detection.py +5 -2
- contentctl/objects/detection_metadata.py +1 -0
- contentctl/objects/detection_stanza.py +7 -2
- contentctl/objects/detection_tags.py +54 -64
- contentctl/objects/drilldown.py +66 -35
- contentctl/objects/enums.py +61 -43
- contentctl/objects/errors.py +16 -24
- contentctl/objects/integration_test.py +3 -3
- contentctl/objects/integration_test_result.py +1 -0
- contentctl/objects/investigation.py +41 -26
- contentctl/objects/investigation_tags.py +29 -17
- contentctl/objects/lookup.py +234 -113
- contentctl/objects/macro.py +55 -38
- contentctl/objects/manual_test.py +3 -3
- contentctl/objects/manual_test_result.py +1 -0
- contentctl/objects/mitre_attack_enrichment.py +17 -16
- contentctl/objects/notable_action.py +2 -1
- contentctl/objects/notable_event.py +1 -3
- contentctl/objects/playbook.py +37 -35
- contentctl/objects/playbook_tags.py +22 -16
- contentctl/objects/rba.py +14 -8
- contentctl/objects/risk_analysis_action.py +15 -11
- contentctl/objects/risk_event.py +27 -20
- contentctl/objects/risk_object.py +1 -0
- contentctl/objects/savedsearches_conf.py +9 -7
- contentctl/objects/security_content_object.py +5 -2
- contentctl/objects/story.py +45 -44
- contentctl/objects/story_tags.py +56 -44
- contentctl/objects/test_group.py +5 -2
- contentctl/objects/threat_object.py +1 -0
- contentctl/objects/throttling.py +27 -18
- contentctl/objects/unit_test.py +3 -4
- contentctl/objects/unit_test_baseline.py +4 -5
- contentctl/objects/unit_test_result.py +6 -6
- contentctl/output/api_json_output.py +22 -22
- contentctl/output/attack_nav_output.py +21 -21
- contentctl/output/attack_nav_writer.py +29 -37
- contentctl/output/conf_output.py +230 -174
- contentctl/output/data_source_writer.py +38 -25
- contentctl/output/doc_md_output.py +53 -27
- contentctl/output/jinja_writer.py +19 -15
- contentctl/output/json_writer.py +20 -8
- contentctl/output/svg_output.py +56 -38
- contentctl/output/templates/savedsearches_detections.j2 +1 -1
- contentctl/output/templates/transforms.j2 +2 -2
- contentctl/output/yml_writer.py +18 -24
- {contentctl-5.0.0a0.dist-info → contentctl-5.0.0a3.dist-info}/METADATA +1 -1
- contentctl-5.0.0a3.dist-info/RECORD +168 -0
- contentctl/actions/initialize_old.py +0 -245
- contentctl/objects/observable.py +0 -39
- contentctl-5.0.0a0.dist-info/RECORD +0 -170
- {contentctl-5.0.0a0.dist-info → contentctl-5.0.0a3.dist-info}/LICENSE.md +0 -0
- {contentctl-5.0.0a0.dist-info → contentctl-5.0.0a3.dist-info}/WHEEL +0 -0
- {contentctl-5.0.0a0.dist-info → contentctl-5.0.0a3.dist-info}/entry_points.txt +0 -0
contentctl/objects/dashboard.py
CHANGED
|
@@ -8,7 +8,7 @@ from contentctl.objects.security_content_object import SecurityContentObject
|
|
|
8
8
|
from contentctl.objects.config import build
|
|
9
9
|
from enum import StrEnum
|
|
10
10
|
|
|
11
|
-
DEFAULT_DASHBAORD_JINJA2_TEMPLATE =
|
|
11
|
+
DEFAULT_DASHBAORD_JINJA2_TEMPLATE = """<dashboard version="2" theme="{{ dashboard.theme }}">
|
|
12
12
|
<label>{{ dashboard.label(config) }}</label>
|
|
13
13
|
<description></description>
|
|
14
14
|
<definition><![CDATA[
|
|
@@ -21,28 +21,40 @@ DEFAULT_DASHBAORD_JINJA2_TEMPLATE = '''<dashboard version="2" theme="{{ dashboar
|
|
|
21
21
|
"hideExport": false
|
|
22
22
|
}
|
|
23
23
|
]]></meta>
|
|
24
|
-
</dashboard>
|
|
24
|
+
</dashboard>"""
|
|
25
|
+
|
|
25
26
|
|
|
26
27
|
class DashboardTheme(StrEnum):
|
|
27
28
|
light = "light"
|
|
28
29
|
dark = "dark"
|
|
29
30
|
|
|
31
|
+
|
|
30
32
|
class Dashboard(SecurityContentObject):
|
|
31
|
-
j2_template: str = Field(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
33
|
+
j2_template: str = Field(
|
|
34
|
+
default=DEFAULT_DASHBAORD_JINJA2_TEMPLATE,
|
|
35
|
+
description="Jinja2 Template used to construct the dashboard",
|
|
36
|
+
)
|
|
37
|
+
description: str = Field(
|
|
38
|
+
...,
|
|
39
|
+
description="A description of the dashboard. This does not have to match "
|
|
40
|
+
"the description of the dashboard in the JSON file.",
|
|
41
|
+
max_length=10000,
|
|
42
|
+
)
|
|
43
|
+
theme: DashboardTheme = Field(
|
|
44
|
+
default=DashboardTheme.light,
|
|
45
|
+
description="The theme of the dashboard. Choose between 'light' and 'dark'.",
|
|
46
|
+
)
|
|
47
|
+
json_obj: Json[dict[str, Any]] = Field(
|
|
48
|
+
..., description="Valid JSON object that describes the dashboard"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
def label(self, config: build) -> str:
|
|
40
52
|
return f"{config.app.label} - {self.name}"
|
|
41
|
-
|
|
53
|
+
|
|
42
54
|
@model_validator(mode="before")
|
|
43
55
|
@classmethod
|
|
44
|
-
def validate_fields_from_json(cls, data:Any)->Any:
|
|
45
|
-
yml_file_name:str|None = data.get("file_path", None)
|
|
56
|
+
def validate_fields_from_json(cls, data: Any) -> Any:
|
|
57
|
+
yml_file_name: str | None = data.get("file_path", None)
|
|
46
58
|
if yml_file_name is None:
|
|
47
59
|
raise ValueError("File name not passed to dashboard constructor")
|
|
48
60
|
yml_file_path = pathlib.Path(yml_file_name)
|
|
@@ -50,51 +62,53 @@ class Dashboard(SecurityContentObject):
|
|
|
50
62
|
|
|
51
63
|
if not json_file_path.is_file():
|
|
52
64
|
raise ValueError(f"Required file {json_file_path} does not exist.")
|
|
53
|
-
|
|
54
|
-
with open(json_file_path,
|
|
65
|
+
|
|
66
|
+
with open(json_file_path, "r") as jsonFilePointer:
|
|
55
67
|
try:
|
|
56
|
-
json_obj:dict[str,Any] = json.load(jsonFilePointer)
|
|
68
|
+
json_obj: dict[str, Any] = json.load(jsonFilePointer)
|
|
57
69
|
except Exception as e:
|
|
58
70
|
raise ValueError(f"Unable to load data from {json_file_path}: {str(e)}")
|
|
59
71
|
|
|
60
|
-
name_from_file = data.get("name",None)
|
|
61
|
-
name_from_json
|
|
72
|
+
name_from_file = data.get("name", None)
|
|
73
|
+
name_from_json = json_obj.get("title", None)
|
|
62
74
|
|
|
63
|
-
errors:list[str] = []
|
|
75
|
+
errors: list[str] = []
|
|
64
76
|
if name_from_json is None:
|
|
65
77
|
errors.append(f"'title' field is missing from {json_file_path}")
|
|
66
78
|
elif name_from_json != name_from_file:
|
|
67
|
-
errors.append(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
79
|
+
errors.append(
|
|
80
|
+
f"The 'title' field in the JSON file [{json_file_path}] does not match the 'name' field in the YML object [{yml_file_path}]. These two MUST match:\n "
|
|
81
|
+
f"title in JSON : {name_from_json}\n "
|
|
82
|
+
f"title in YML : {name_from_file}\n "
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
description_from_json = json_obj.get("description", None)
|
|
72
86
|
if description_from_json is None:
|
|
73
87
|
errors.append("'description' field is missing from field 'json_object'")
|
|
74
|
-
|
|
75
|
-
if len(errors) > 0
|
|
88
|
+
|
|
89
|
+
if len(errors) > 0:
|
|
76
90
|
err_string = "\n - ".join(errors)
|
|
77
91
|
raise ValueError(f"Error(s) validating dashboard:\n - {err_string}")
|
|
78
|
-
|
|
79
|
-
data[
|
|
80
|
-
data[
|
|
92
|
+
|
|
93
|
+
data["name"] = name_from_file
|
|
94
|
+
data["json_obj"] = json.dumps(json_obj)
|
|
81
95
|
return data
|
|
82
96
|
|
|
83
|
-
|
|
84
97
|
def pretty_print_json_obj(self):
|
|
85
98
|
return json.dumps(self.json_obj, indent=4)
|
|
86
|
-
|
|
87
|
-
def getOutputFilepathRelativeToAppRoot(self, config:build)->pathlib.Path:
|
|
99
|
+
|
|
100
|
+
def getOutputFilepathRelativeToAppRoot(self, config: build) -> pathlib.Path:
|
|
88
101
|
filename = f"{self.file_path.stem}.xml".lower()
|
|
89
|
-
return pathlib.Path("default/data/ui/views")/filename
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def writeDashboardFile(self, j2_env:Environment, config:build):
|
|
102
|
+
return pathlib.Path("default/data/ui/views") / filename
|
|
103
|
+
|
|
104
|
+
def writeDashboardFile(self, j2_env: Environment, config: build):
|
|
93
105
|
template = j2_env.from_string(self.j2_template)
|
|
94
106
|
dashboard_text = template.render(config=config, dashboard=self)
|
|
95
107
|
|
|
96
|
-
with open(
|
|
97
|
-
|
|
108
|
+
with open(
|
|
109
|
+
config.getPackageDirectoryPath()
|
|
110
|
+
/ self.getOutputFilepathRelativeToAppRoot(config),
|
|
111
|
+
"a",
|
|
112
|
+
) as f:
|
|
113
|
+
output_xml = dashboard_text.encode("utf-8", "ignore").decode("utf-8")
|
|
98
114
|
f.write(output_xml)
|
|
99
|
-
|
|
100
|
-
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
from typing import Optional, Any
|
|
3
|
-
from pydantic import Field, HttpUrl, model_serializer, BaseModel
|
|
3
|
+
from pydantic import Field, HttpUrl, model_serializer, BaseModel
|
|
4
4
|
from contentctl.objects.security_content_object import SecurityContentObject
|
|
5
5
|
|
|
6
6
|
|
|
@@ -8,6 +8,8 @@ class TA(BaseModel):
|
|
|
8
8
|
name: str
|
|
9
9
|
url: HttpUrl | None = None
|
|
10
10
|
version: str
|
|
11
|
+
|
|
12
|
+
|
|
11
13
|
class DataSource(SecurityContentObject):
|
|
12
14
|
source: str = Field(...)
|
|
13
15
|
sourcetype: str = Field(...)
|
|
@@ -19,14 +21,13 @@ class DataSource(SecurityContentObject):
|
|
|
19
21
|
convert_to_log_source: None | list = None
|
|
20
22
|
example_log: None | str = None
|
|
21
23
|
|
|
22
|
-
|
|
23
24
|
@model_serializer
|
|
24
25
|
def serialize_model(self):
|
|
25
|
-
#Call serializer for parent
|
|
26
|
+
# Call serializer for parent
|
|
26
27
|
super_fields = super().serialize_model()
|
|
27
|
-
|
|
28
|
-
#All fields custom to this model
|
|
29
|
-
model:dict[str,Any] = {
|
|
28
|
+
|
|
29
|
+
# All fields custom to this model
|
|
30
|
+
model: dict[str, Any] = {
|
|
30
31
|
"source": self.source,
|
|
31
32
|
"sourcetype": self.sourcetype,
|
|
32
33
|
"separator": self.separator,
|
|
@@ -35,12 +36,11 @@ class DataSource(SecurityContentObject):
|
|
|
35
36
|
"fields": self.fields,
|
|
36
37
|
"field_mappings": self.field_mappings,
|
|
37
38
|
"convert_to_log_source": self.convert_to_log_source,
|
|
38
|
-
"example_log":self.example_log
|
|
39
|
+
"example_log": self.example_log,
|
|
39
40
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
#Combine fields from this model with fields from parent
|
|
41
|
+
|
|
42
|
+
# Combine fields from this model with fields from parent
|
|
43
43
|
super_fields.update(model)
|
|
44
|
-
|
|
45
|
-
#return the model
|
|
46
|
-
return super_fields
|
|
44
|
+
|
|
45
|
+
# return the model
|
|
46
|
+
return super_fields
|
contentctl/objects/deployment.py
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from pydantic import
|
|
2
|
+
from pydantic import (
|
|
3
|
+
Field,
|
|
4
|
+
computed_field,
|
|
5
|
+
ValidationInfo,
|
|
6
|
+
model_serializer,
|
|
7
|
+
NonNegativeInt,
|
|
8
|
+
)
|
|
3
9
|
from typing import Any
|
|
4
10
|
import uuid
|
|
5
11
|
import datetime
|
|
@@ -10,68 +16,69 @@ from contentctl.objects.alert_action import AlertAction
|
|
|
10
16
|
from contentctl.objects.enums import DeploymentType
|
|
11
17
|
|
|
12
18
|
|
|
13
|
-
class Deployment(SecurityContentObject):
|
|
19
|
+
class Deployment(SecurityContentObject):
|
|
14
20
|
scheduling: DeploymentScheduling = Field(...)
|
|
15
21
|
alert_action: AlertAction = AlertAction()
|
|
16
22
|
type: DeploymentType = Field(...)
|
|
17
|
-
author: str = Field(...,max_length=255)
|
|
23
|
+
author: str = Field(..., max_length=255)
|
|
18
24
|
version: NonNegativeInt = 1
|
|
19
25
|
|
|
20
|
-
#Type was the only tag exposed and should likely be removed/refactored.
|
|
21
|
-
#For transitional reasons, provide this as a computed_field in prep for removal
|
|
26
|
+
# Type was the only tag exposed and should likely be removed/refactored.
|
|
27
|
+
# For transitional reasons, provide this as a computed_field in prep for removal
|
|
22
28
|
@computed_field
|
|
23
29
|
@property
|
|
24
|
-
def tags(self)->dict[str,DeploymentType]:
|
|
30
|
+
def tags(self) -> dict[str, DeploymentType]:
|
|
25
31
|
return {"type": self.type}
|
|
26
32
|
|
|
27
|
-
|
|
28
33
|
@staticmethod
|
|
29
|
-
def getDeployment(v:dict[str,Any], info:ValidationInfo)->Deployment:
|
|
34
|
+
def getDeployment(v: dict[str, Any], info: ValidationInfo) -> Deployment:
|
|
30
35
|
if v != {}:
|
|
31
36
|
# If the user has defined a deployment, then allow it to be validated
|
|
32
37
|
# and override the default deployment info defined in type:Baseline
|
|
33
|
-
v[
|
|
34
|
-
|
|
38
|
+
v["type"] = DeploymentType.Embedded
|
|
39
|
+
|
|
35
40
|
detection_name = info.data.get("name", None)
|
|
36
41
|
if detection_name is None:
|
|
37
|
-
raise ValueError(
|
|
42
|
+
raise ValueError(
|
|
43
|
+
"Could not create inline deployment - Baseline or Detection lacking 'name' field,"
|
|
44
|
+
)
|
|
38
45
|
|
|
39
|
-
# Add a number of static values
|
|
40
|
-
v.update(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
46
|
+
# Add a number of static values
|
|
47
|
+
v.update(
|
|
48
|
+
{
|
|
49
|
+
"name": f"{detection_name} - Inline Deployment",
|
|
50
|
+
"id": uuid.uuid4(),
|
|
51
|
+
"date": datetime.date.today(),
|
|
52
|
+
"description": "Inline deployment created at runtime.",
|
|
53
|
+
"author": "contentctl tool",
|
|
54
|
+
}
|
|
55
|
+
)
|
|
47
56
|
|
|
48
|
-
|
|
49
57
|
# This constructs a temporary in-memory deployment,
|
|
50
|
-
# allowing the deployment to be easily defined in the
|
|
58
|
+
# allowing the deployment to be easily defined in the
|
|
51
59
|
# detection on a per detection basis.
|
|
52
60
|
return Deployment.model_validate(v)
|
|
53
|
-
|
|
61
|
+
|
|
54
62
|
else:
|
|
55
|
-
return SecurityContentObject.getDeploymentFromType(
|
|
56
|
-
|
|
63
|
+
return SecurityContentObject.getDeploymentFromType(
|
|
64
|
+
info.data.get("type", None), info
|
|
65
|
+
)
|
|
66
|
+
|
|
57
67
|
@model_serializer
|
|
58
68
|
def serialize_model(self):
|
|
59
|
-
#Call serializer for parent
|
|
69
|
+
# Call serializer for parent
|
|
60
70
|
super_fields = super().serialize_model()
|
|
61
|
-
|
|
62
|
-
#All fields custom to this model
|
|
63
|
-
model= {
|
|
64
|
-
"scheduling": self.scheduling.model_dump(),
|
|
65
|
-
"tags": self.tags
|
|
66
|
-
}
|
|
67
71
|
|
|
68
|
-
#
|
|
72
|
+
# All fields custom to this model
|
|
73
|
+
model = {"scheduling": self.scheduling.model_dump(), "tags": self.tags}
|
|
74
|
+
|
|
75
|
+
# Combine fields from this model with fields from parent
|
|
69
76
|
model.update(super_fields)
|
|
70
|
-
|
|
77
|
+
|
|
71
78
|
alert_action_fields = self.alert_action.model_dump()
|
|
72
79
|
model.update(alert_action_fields)
|
|
73
80
|
|
|
74
|
-
del
|
|
75
|
-
|
|
76
|
-
#return the model
|
|
77
|
-
return model
|
|
81
|
+
del model["references"]
|
|
82
|
+
|
|
83
|
+
# return the model
|
|
84
|
+
return model
|
|
@@ -2,8 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
from pydantic import BaseModel, ConfigDict
|
|
3
3
|
from typing import List
|
|
4
4
|
|
|
5
|
+
|
|
5
6
|
class DeploymentNotable(BaseModel):
|
|
6
7
|
model_config = ConfigDict(extra="forbid")
|
|
7
8
|
rule_description: str
|
|
8
9
|
rule_title: str
|
|
9
|
-
nes_fields: List[str]
|
|
10
|
+
nes_fields: List[str]
|
|
@@ -4,8 +4,8 @@ from pydantic import BaseModel, ConfigDict
|
|
|
4
4
|
|
|
5
5
|
class DeploymentPhantom(BaseModel):
|
|
6
6
|
model_config = ConfigDict(extra="forbid")
|
|
7
|
-
cam_workers
|
|
8
|
-
label
|
|
9
|
-
phantom_server
|
|
10
|
-
sensitivity
|
|
11
|
-
severity
|
|
7
|
+
cam_workers: str
|
|
8
|
+
label: str
|
|
9
|
+
phantom_server: str
|
|
10
|
+
sensitivity: str
|
|
11
|
+
severity: str
|
contentctl/objects/detection.py
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from contentctl.objects.abstract_security_content_objects.detection_abstract import
|
|
2
|
+
from contentctl.objects.abstract_security_content_objects.detection_abstract import (
|
|
3
|
+
Detection_Abstract,
|
|
4
|
+
)
|
|
5
|
+
|
|
3
6
|
|
|
4
7
|
class Detection(Detection_Abstract):
|
|
5
8
|
# Customization to the Detection Class go here.
|
|
@@ -12,4 +15,4 @@ class Detection(Detection_Abstract):
|
|
|
12
15
|
# them or modifying their behavior may cause
|
|
13
16
|
# undefined issues with the contentctl tooling
|
|
14
17
|
# or output of the tooling.
|
|
15
|
-
pass
|
|
18
|
+
pass
|
|
@@ -11,6 +11,7 @@ class DetectionStanza(BaseModel):
|
|
|
11
11
|
"""
|
|
12
12
|
A model representing a stanza for a detection in savedsearches.conf
|
|
13
13
|
"""
|
|
14
|
+
|
|
14
15
|
# The lines that comprise this stanza, in the order they appear in the conf
|
|
15
16
|
lines: list[str] = Field(...)
|
|
16
17
|
|
|
@@ -47,7 +48,9 @@ class DetectionStanza(BaseModel):
|
|
|
47
48
|
raise Exception(f"No metadata for detection '{self.name}' found in stanza.")
|
|
48
49
|
|
|
49
50
|
# Parse the metadata JSON into a model
|
|
50
|
-
return DetectionMetadata.model_validate_json(
|
|
51
|
+
return DetectionMetadata.model_validate_json(
|
|
52
|
+
meta_line[len(DetectionStanza.METADATA_LINE_PREFIX) :]
|
|
53
|
+
)
|
|
51
54
|
|
|
52
55
|
@computed_field
|
|
53
56
|
@cached_property
|
|
@@ -76,4 +79,6 @@ class DetectionStanza(BaseModel):
|
|
|
76
79
|
:returns: True if the version still needs to be bumped
|
|
77
80
|
:rtype: bool
|
|
78
81
|
"""
|
|
79
|
-
return (self.hash != previous.hash) and (
|
|
82
|
+
return (self.hash != previous.hash) and (
|
|
83
|
+
self.metadata.detection_version <= previous.metadata.detection_version
|
|
84
|
+
)
|
|
@@ -1,42 +1,45 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
import uuid
|
|
3
4
|
from typing import TYPE_CHECKING, List, Optional, Union
|
|
5
|
+
|
|
4
6
|
from pydantic import (
|
|
7
|
+
UUID4,
|
|
5
8
|
BaseModel,
|
|
9
|
+
ConfigDict,
|
|
6
10
|
Field,
|
|
7
|
-
computed_field,
|
|
8
|
-
UUID4,
|
|
9
11
|
HttpUrl,
|
|
10
|
-
ConfigDict,
|
|
11
|
-
field_validator,
|
|
12
12
|
ValidationInfo,
|
|
13
|
+
computed_field,
|
|
14
|
+
field_validator,
|
|
13
15
|
model_serializer,
|
|
14
|
-
model_validator
|
|
16
|
+
model_validator,
|
|
15
17
|
)
|
|
18
|
+
|
|
16
19
|
from contentctl.objects.story import Story
|
|
17
20
|
from contentctl.objects.throttling import Throttling
|
|
21
|
+
|
|
18
22
|
if TYPE_CHECKING:
|
|
19
23
|
from contentctl.input.director import DirectorOutputDto
|
|
20
24
|
|
|
21
|
-
from contentctl.objects.
|
|
25
|
+
from contentctl.objects.annotated_types import CVE_TYPE, MITRE_ATTACK_ID_TYPE
|
|
26
|
+
from contentctl.objects.atomic import AtomicEnrichment, AtomicTest
|
|
22
27
|
from contentctl.objects.constants import ATTACK_TACTICS_KILLCHAIN_MAPPING
|
|
23
|
-
from contentctl.objects.observable import Observable
|
|
24
28
|
from contentctl.objects.enums import (
|
|
25
|
-
Cis18Value,
|
|
26
29
|
AssetType,
|
|
27
|
-
|
|
30
|
+
Cis18Value,
|
|
28
31
|
KillChainPhase,
|
|
29
32
|
NistCategory,
|
|
30
|
-
SecurityContentProductName
|
|
33
|
+
SecurityContentProductName,
|
|
34
|
+
SecurityDomain,
|
|
31
35
|
)
|
|
32
|
-
from contentctl.objects.
|
|
33
|
-
from contentctl.objects.annotated_types import MITRE_ATTACK_ID_TYPE, CVE_TYPE
|
|
36
|
+
from contentctl.objects.mitre_attack_enrichment import MitreAttackEnrichment
|
|
34
37
|
|
|
35
38
|
|
|
36
39
|
class DetectionTags(BaseModel):
|
|
37
40
|
# detection spec
|
|
38
41
|
|
|
39
|
-
model_config = ConfigDict(validate_default=False, extra=
|
|
42
|
+
model_config = ConfigDict(validate_default=False, extra="forbid")
|
|
40
43
|
analytic_story: list[Story] = Field(...)
|
|
41
44
|
asset_type: AssetType = Field(...)
|
|
42
45
|
group: list[str] = []
|
|
@@ -44,9 +47,6 @@ class DetectionTags(BaseModel):
|
|
|
44
47
|
mitre_attack_id: List[MITRE_ATTACK_ID_TYPE] = []
|
|
45
48
|
nist: list[NistCategory] = []
|
|
46
49
|
|
|
47
|
-
# TODO (cmcginley): observable should be removed as well, yes?
|
|
48
|
-
# TODO (#249): Add pydantic validator to ensure observables are unique within a detection
|
|
49
|
-
observable: List[Observable] = []
|
|
50
50
|
product: list[SecurityContentProductName] = Field(..., min_length=1)
|
|
51
51
|
throttling: Optional[Throttling] = None
|
|
52
52
|
security_domain: SecurityDomain = Field(...)
|
|
@@ -54,7 +54,9 @@ class DetectionTags(BaseModel):
|
|
|
54
54
|
atomic_guid: List[AtomicTest] = []
|
|
55
55
|
|
|
56
56
|
# enrichment
|
|
57
|
-
mitre_attack_enrichments: List[MitreAttackEnrichment] = Field(
|
|
57
|
+
mitre_attack_enrichments: List[MitreAttackEnrichment] = Field(
|
|
58
|
+
[], validate_default=True
|
|
59
|
+
)
|
|
58
60
|
|
|
59
61
|
@computed_field
|
|
60
62
|
@property
|
|
@@ -84,38 +86,6 @@ class DetectionTags(BaseModel):
|
|
|
84
86
|
# TODO (#268): Validate manual_test has length > 0 if not None
|
|
85
87
|
manual_test: Optional[str] = None
|
|
86
88
|
|
|
87
|
-
# The following validator is temporarily disabled pending further discussions
|
|
88
|
-
# @validator('message')
|
|
89
|
-
# def validate_message(cls,v,values):
|
|
90
|
-
|
|
91
|
-
# observables:list[Observable] = values.get("observable",[])
|
|
92
|
-
# observable_names = set([o.name for o in observables])
|
|
93
|
-
# #find all of the observables used in the message by name
|
|
94
|
-
# name_match_regex = r"\$([^\s.]*)\$"
|
|
95
|
-
|
|
96
|
-
# message_observables = set()
|
|
97
|
-
|
|
98
|
-
# #Make sure that all observable names in
|
|
99
|
-
# for match in re.findall(name_match_regex, v):
|
|
100
|
-
# #Remove
|
|
101
|
-
# match_without_dollars = match.replace("$", "")
|
|
102
|
-
# message_observables.add(match_without_dollars)
|
|
103
|
-
|
|
104
|
-
# missing_observables = message_observables - observable_names
|
|
105
|
-
# unused_observables = observable_names - message_observables
|
|
106
|
-
# if len(missing_observables) > 0:
|
|
107
|
-
# raise ValueError(
|
|
108
|
-
# "The following observables are referenced in the message, but were not declared as"
|
|
109
|
-
# f" observables: {missing_observables}"
|
|
110
|
-
# )
|
|
111
|
-
|
|
112
|
-
# if len(unused_observables) > 0:
|
|
113
|
-
# raise ValueError(
|
|
114
|
-
# "The following observables were declared, but are not referenced in the message:"
|
|
115
|
-
# f" {unused_observables}"
|
|
116
|
-
# )
|
|
117
|
-
# return v
|
|
118
|
-
|
|
119
89
|
@model_serializer
|
|
120
90
|
def serialize_model(self):
|
|
121
91
|
# Since this field has no parent, there is no need to call super() serialization function
|
|
@@ -127,7 +97,7 @@ class DetectionTags(BaseModel):
|
|
|
127
97
|
"nist": self.nist,
|
|
128
98
|
"security_domain": self.security_domain,
|
|
129
99
|
"mitre_attack_id": self.mitre_attack_id,
|
|
130
|
-
"mitre_attack_enrichments": self.mitre_attack_enrichments
|
|
100
|
+
"mitre_attack_enrichments": self.mitre_attack_enrichments,
|
|
131
101
|
}
|
|
132
102
|
|
|
133
103
|
@model_validator(mode="after")
|
|
@@ -141,9 +111,13 @@ class DetectionTags(BaseModel):
|
|
|
141
111
|
f" at runtime. Instead, this field contained: {self.mitre_attack_enrichments}"
|
|
142
112
|
)
|
|
143
113
|
|
|
144
|
-
output_dto: Union[DirectorOutputDto, None] = info.context.get(
|
|
114
|
+
output_dto: Union[DirectorOutputDto, None] = info.context.get(
|
|
115
|
+
"output_dto", None
|
|
116
|
+
)
|
|
145
117
|
if output_dto is None:
|
|
146
|
-
raise ValueError(
|
|
118
|
+
raise ValueError(
|
|
119
|
+
"Context not provided to detection.detection_tags model post validator"
|
|
120
|
+
)
|
|
147
121
|
|
|
148
122
|
if output_dto.attack_enrichment.use_enrichment is False:
|
|
149
123
|
return self
|
|
@@ -152,7 +126,9 @@ class DetectionTags(BaseModel):
|
|
|
152
126
|
missing_tactics: list[str] = []
|
|
153
127
|
for mitre_attack_id in self.mitre_attack_id:
|
|
154
128
|
try:
|
|
155
|
-
mitre_enrichments.append(
|
|
129
|
+
mitre_enrichments.append(
|
|
130
|
+
output_dto.attack_enrichment.getEnrichmentByMitreID(mitre_attack_id)
|
|
131
|
+
)
|
|
156
132
|
except Exception:
|
|
157
133
|
missing_tactics.append(mitre_attack_id)
|
|
158
134
|
|
|
@@ -163,7 +139,7 @@ class DetectionTags(BaseModel):
|
|
|
163
139
|
|
|
164
140
|
return self
|
|
165
141
|
|
|
166
|
-
|
|
142
|
+
"""
|
|
167
143
|
@field_validator('mitre_attack_enrichments', mode="before")
|
|
168
144
|
@classmethod
|
|
169
145
|
def addAttackEnrichments(cls, v:list[MitreAttackEnrichment], info:ValidationInfo)->list[MitreAttackEnrichment]:
|
|
@@ -181,31 +157,43 @@ class DetectionTags(BaseModel):
|
|
|
181
157
|
enrichments = []
|
|
182
158
|
|
|
183
159
|
return enrichments
|
|
184
|
-
|
|
160
|
+
"""
|
|
185
161
|
|
|
186
|
-
@field_validator(
|
|
162
|
+
@field_validator("analytic_story", mode="before")
|
|
187
163
|
@classmethod
|
|
188
|
-
def mapStoryNamesToStoryObjects(
|
|
164
|
+
def mapStoryNamesToStoryObjects(
|
|
165
|
+
cls, v: list[str], info: ValidationInfo
|
|
166
|
+
) -> list[Story]:
|
|
189
167
|
if info.context is None:
|
|
190
168
|
raise ValueError("ValidationInfo.context unexpectedly null")
|
|
191
169
|
|
|
192
|
-
return Story.mapNamesToSecurityContentObjects(
|
|
170
|
+
return Story.mapNamesToSecurityContentObjects(
|
|
171
|
+
v, info.context.get("output_dto", None)
|
|
172
|
+
)
|
|
193
173
|
|
|
194
174
|
def getAtomicGuidStringArray(self) -> List[str]:
|
|
195
|
-
return [
|
|
175
|
+
return [
|
|
176
|
+
str(atomic_guid.auto_generated_guid) for atomic_guid in self.atomic_guid
|
|
177
|
+
]
|
|
196
178
|
|
|
197
|
-
@field_validator(
|
|
179
|
+
@field_validator("atomic_guid", mode="before")
|
|
198
180
|
@classmethod
|
|
199
|
-
def mapAtomicGuidsToAtomicTests(
|
|
181
|
+
def mapAtomicGuidsToAtomicTests(
|
|
182
|
+
cls, v: List[UUID4], info: ValidationInfo
|
|
183
|
+
) -> List[AtomicTest]:
|
|
200
184
|
if len(v) == 0:
|
|
201
185
|
return []
|
|
202
186
|
|
|
203
187
|
if info.context is None:
|
|
204
188
|
raise ValueError("ValidationInfo.context unexpectedly null")
|
|
205
189
|
|
|
206
|
-
output_dto: Union[DirectorOutputDto, None] = info.context.get(
|
|
190
|
+
output_dto: Union[DirectorOutputDto, None] = info.context.get(
|
|
191
|
+
"output_dto", None
|
|
192
|
+
)
|
|
207
193
|
if output_dto is None:
|
|
208
|
-
raise ValueError(
|
|
194
|
+
raise ValueError(
|
|
195
|
+
"Context not provided to detection.detection_tags.atomic_guid validator"
|
|
196
|
+
)
|
|
209
197
|
|
|
210
198
|
atomic_enrichment: AtomicEnrichment = output_dto.atomic_enrichment
|
|
211
199
|
|
|
@@ -247,4 +235,6 @@ class DetectionTags(BaseModel):
|
|
|
247
235
|
elif len(missing_tests) > 0:
|
|
248
236
|
raise ValueError(missing_tests_string)
|
|
249
237
|
|
|
250
|
-
return matched_tests + [
|
|
238
|
+
return matched_tests + [
|
|
239
|
+
AtomicTest.AtomicTestWhenTestIsMissing(test) for test in missing_tests
|
|
240
|
+
]
|