contentctl 5.0.0a2__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 +2 -4
- 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 +10 -10
- 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 +1 -1
- 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/transforms.j2 +2 -2
- contentctl/output/yml_writer.py +18 -24
- {contentctl-5.0.0a2.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.0a2.dist-info/RECORD +0 -170
- {contentctl-5.0.0a2.dist-info → contentctl-5.0.0a3.dist-info}/LICENSE.md +0 -0
- {contentctl-5.0.0a2.dist-info → contentctl-5.0.0a3.dist-info}/WHEEL +0 -0
- {contentctl-5.0.0a2.dist-info → contentctl-5.0.0a3.dist-info}/entry_points.txt +0 -0
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
|
+
]
|
contentctl/objects/drilldown.py
CHANGED
|
@@ -1,71 +1,102 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
|
|
2
|
+
|
|
3
3
|
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field, model_serializer
|
|
6
|
+
|
|
4
7
|
if TYPE_CHECKING:
|
|
5
8
|
from contentctl.objects.detection import Detection
|
|
9
|
+
|
|
6
10
|
from contentctl.objects.enums import AnalyticsType
|
|
11
|
+
|
|
7
12
|
DRILLDOWN_SEARCH_PLACEHOLDER = "%original_detection_search%"
|
|
8
13
|
EARLIEST_OFFSET = "$info_min_time$"
|
|
9
14
|
LATEST_OFFSET = "$info_max_time$"
|
|
10
15
|
RISK_SEARCH = "index = risk starthoursago = 168 endhoursago = 0 | stats count values(search_name) values(risk_message) values(analyticstories) values(annotations._all) values(annotations.mitre_attack.mitre_tactic) "
|
|
11
16
|
|
|
17
|
+
|
|
12
18
|
class Drilldown(BaseModel):
|
|
13
19
|
name: str = Field(..., description="The name of the drilldown search", min_length=5)
|
|
14
|
-
search: str = Field(
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
search: str = Field(
|
|
21
|
+
...,
|
|
22
|
+
description="The text of a drilldown search. This must be valid SPL.",
|
|
23
|
+
min_length=1,
|
|
24
|
+
)
|
|
25
|
+
earliest_offset: None | str = Field(
|
|
26
|
+
...,
|
|
27
|
+
description="Earliest offset time for the drilldown search. "
|
|
28
|
+
f"The most common value for this field is '{EARLIEST_OFFSET}', "
|
|
29
|
+
"but it is NOT the default value and must be supplied explicitly.",
|
|
30
|
+
min_length=1,
|
|
31
|
+
)
|
|
32
|
+
latest_offset: None | str = Field(
|
|
33
|
+
...,
|
|
34
|
+
description="Latest offset time for the driolldown search. "
|
|
35
|
+
f"The most common value for this field is '{LATEST_OFFSET}', "
|
|
36
|
+
"but it is NOT the default value and must be supplied explicitly.",
|
|
37
|
+
min_length=1,
|
|
38
|
+
)
|
|
25
39
|
|
|
26
|
-
# TODO (cmcginley): @ljstella the drilldowns will need to be updated
|
|
27
40
|
@classmethod
|
|
28
41
|
def constructDrilldownsFromDetection(cls, detection: Detection) -> list[Drilldown]:
|
|
29
|
-
|
|
42
|
+
# Ensure the rba object is defined
|
|
43
|
+
if detection.rba is None:
|
|
44
|
+
raise NotImplementedError(
|
|
45
|
+
f"Unexpected error: Detection '{detection.name}' has no RBA objects associated "
|
|
46
|
+
"with it; cannot construct drilldowns."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
victim_observables = [o for o in detection.rba.risk_objects]
|
|
30
50
|
if len(victim_observables) == 0 or detection.type == AnalyticsType.Hunting:
|
|
31
51
|
# No victims, so no drilldowns
|
|
32
52
|
return []
|
|
33
53
|
print(f"Adding default drilldowns for [{detection.name}]")
|
|
34
|
-
variableNamesString =
|
|
54
|
+
variableNamesString = " and ".join([f"${o.field}$" for o in victim_observables])
|
|
35
55
|
nameField = f"View the detection results for {variableNamesString}"
|
|
36
|
-
appendedSearch =
|
|
56
|
+
appendedSearch = " | search " + " ".join(
|
|
57
|
+
[f"{o.field} = ${o.field}$" for o in victim_observables]
|
|
58
|
+
)
|
|
37
59
|
search_field = f"{detection.search}{appendedSearch}"
|
|
38
|
-
detection_results = cls(
|
|
39
|
-
|
|
40
|
-
|
|
60
|
+
detection_results = cls(
|
|
61
|
+
name=nameField,
|
|
62
|
+
earliest_offset=EARLIEST_OFFSET,
|
|
63
|
+
latest_offset=LATEST_OFFSET,
|
|
64
|
+
search=search_field,
|
|
65
|
+
)
|
|
66
|
+
|
|
41
67
|
nameField = f"View risk events for the last 7 days for {variableNamesString}"
|
|
42
|
-
fieldNamesListString =
|
|
68
|
+
fieldNamesListString = ", ".join([o.field for o in victim_observables])
|
|
43
69
|
search_field = f"{RISK_SEARCH}by {fieldNamesListString} {appendedSearch}"
|
|
44
|
-
risk_events_last_7_days = cls(
|
|
70
|
+
risk_events_last_7_days = cls(
|
|
71
|
+
name=nameField,
|
|
72
|
+
earliest_offset=None,
|
|
73
|
+
latest_offset=None,
|
|
74
|
+
search=search_field,
|
|
75
|
+
)
|
|
45
76
|
|
|
46
|
-
return [detection_results,risk_events_last_7_days]
|
|
47
|
-
|
|
77
|
+
return [detection_results, risk_events_last_7_days]
|
|
48
78
|
|
|
49
|
-
def perform_search_substitutions(self, detection:Detection)->None:
|
|
79
|
+
def perform_search_substitutions(self, detection: Detection) -> None:
|
|
50
80
|
"""Replaces the field DRILLDOWN_SEARCH_PLACEHOLDER (%original_detection_search%)
|
|
51
81
|
with the search contained in the detection. We do this so that the YML does not
|
|
52
82
|
need the search copy/pasted from the search field into the drilldown object.
|
|
53
83
|
|
|
54
84
|
Args:
|
|
55
85
|
detection (Detection): Detection to be used to update the search field of the drilldown
|
|
56
|
-
"""
|
|
57
|
-
self.search = self.search.replace(
|
|
58
|
-
|
|
86
|
+
"""
|
|
87
|
+
self.search = self.search.replace(
|
|
88
|
+
DRILLDOWN_SEARCH_PLACEHOLDER, detection.search
|
|
89
|
+
)
|
|
59
90
|
|
|
60
91
|
@model_serializer
|
|
61
|
-
def serialize_model(self) -> dict[str,str]:
|
|
62
|
-
#Call serializer for parent
|
|
63
|
-
model:dict[str,str] = {}
|
|
92
|
+
def serialize_model(self) -> dict[str, str]:
|
|
93
|
+
# Call serializer for parent
|
|
94
|
+
model: dict[str, str] = {}
|
|
64
95
|
|
|
65
|
-
model[
|
|
66
|
-
model[
|
|
96
|
+
model["name"] = self.name
|
|
97
|
+
model["search"] = self.search
|
|
67
98
|
if self.earliest_offset is not None:
|
|
68
|
-
model[
|
|
99
|
+
model["earliest_offset"] = self.earliest_offset
|
|
69
100
|
if self.latest_offset is not None:
|
|
70
|
-
model[
|
|
71
|
-
return model
|
|
101
|
+
model["latest_offset"] = self.latest_offset
|
|
102
|
+
return model
|