contentctl 5.0.0a2__py3-none-any.whl → 5.0.1__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 +83 -53
- 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 +255 -323
- contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +111 -46
- 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 +47 -35
- contentctl/objects/baseline_tags.py +29 -22
- contentctl/objects/config.py +1 -1
- contentctl/objects/constants.py +32 -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 +53 -31
- 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 +68 -11
- 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 +54 -49
- 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/analyticstories_detections.j2 +1 -1
- contentctl/output/templates/analyticstories_stories.j2 +1 -1
- contentctl/output/templates/es_investigations_investigations.j2 +1 -1
- contentctl/output/templates/es_investigations_stories.j2 +1 -1
- contentctl/output/templates/savedsearches_baselines.j2 +2 -2
- contentctl/output/templates/savedsearches_detections.j2 +2 -8
- contentctl/output/templates/savedsearches_investigations.j2 +2 -2
- contentctl/output/templates/transforms.j2 +2 -4
- contentctl/output/yml_writer.py +18 -24
- contentctl/templates/stories/cobalt_strike.yml +1 -0
- {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/METADATA +1 -1
- contentctl-5.0.1.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.1.dist-info}/LICENSE.md +0 -0
- {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/WHEEL +0 -0
- {contentctl-5.0.0a2.dist-info → contentctl-5.0.1.dist-info}/entry_points.txt +0 -0
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
from pydantic import BaseModel, Field, ConfigDict, HttpUrl, field_validator
|
|
3
|
-
from typing import List
|
|
3
|
+
from typing import List
|
|
4
4
|
from enum import StrEnum
|
|
5
5
|
import datetime
|
|
6
6
|
from contentctl.objects.annotated_types import MITRE_ATTACK_ID_TYPE
|
|
7
7
|
|
|
8
|
+
|
|
8
9
|
class MitreTactics(StrEnum):
|
|
9
10
|
RECONNAISSANCE = "Reconnaissance"
|
|
10
11
|
RESOURCE_DEVELOPMENT = "Resource Development"
|
|
@@ -31,16 +32,17 @@ class AttackGroupMatrix(StrEnum):
|
|
|
31
32
|
class AttackGroupType(StrEnum):
|
|
32
33
|
intrusion_set = "intrusion-set"
|
|
33
34
|
|
|
35
|
+
|
|
34
36
|
class MitreExternalReference(BaseModel):
|
|
35
|
-
model_config = ConfigDict(extra=
|
|
37
|
+
model_config = ConfigDict(extra="forbid")
|
|
36
38
|
source_name: str
|
|
37
|
-
external_id: None | str = None
|
|
39
|
+
external_id: None | str = None
|
|
38
40
|
url: None | HttpUrl = None
|
|
39
41
|
description: None | str = None
|
|
40
42
|
|
|
41
43
|
|
|
42
44
|
class MitreAttackGroup(BaseModel):
|
|
43
|
-
model_config = ConfigDict(extra=
|
|
45
|
+
model_config = ConfigDict(extra="forbid")
|
|
44
46
|
contributors: list[str] = []
|
|
45
47
|
created: datetime.datetime
|
|
46
48
|
created_by_ref: str
|
|
@@ -53,45 +55,44 @@ class MitreAttackGroup(BaseModel):
|
|
|
53
55
|
matrix: list[AttackGroupMatrix]
|
|
54
56
|
mitre_attack_spec_version: None | str
|
|
55
57
|
mitre_version: str
|
|
56
|
-
#assume that if the deprecated field is not present, then the group is not deprecated
|
|
58
|
+
# assume that if the deprecated field is not present, then the group is not deprecated
|
|
57
59
|
mitre_deprecated: bool
|
|
58
60
|
modified: datetime.datetime
|
|
59
61
|
modified_by_ref: str
|
|
60
62
|
object_marking_refs: list[str]
|
|
61
63
|
type: AttackGroupType
|
|
62
64
|
url: HttpUrl
|
|
63
|
-
|
|
64
65
|
|
|
65
66
|
@field_validator("mitre_deprecated", mode="before")
|
|
66
|
-
def standardize_mitre_deprecated(cls, mitre_deprecated:bool | None) -> bool:
|
|
67
|
-
|
|
67
|
+
def standardize_mitre_deprecated(cls, mitre_deprecated: bool | None) -> bool:
|
|
68
|
+
"""
|
|
68
69
|
For some reason, the API will return either a bool for mitre_deprecated OR
|
|
69
70
|
None. We simplify our typing by converting None to False, and assuming that
|
|
70
71
|
if deprecated is None, then the group is not deprecated.
|
|
71
|
-
|
|
72
|
+
"""
|
|
72
73
|
if mitre_deprecated is None:
|
|
73
74
|
return False
|
|
74
75
|
return mitre_deprecated
|
|
75
76
|
|
|
76
77
|
@field_validator("contributors", mode="before")
|
|
77
|
-
def standardize_contributors(cls, contributors:list[str] | None) -> list[str]:
|
|
78
|
-
|
|
78
|
+
def standardize_contributors(cls, contributors: list[str] | None) -> list[str]:
|
|
79
|
+
"""
|
|
79
80
|
For some reason, the API will return either a list of strings for contributors OR
|
|
80
81
|
None. We simplify our typing by converting None to an empty list.
|
|
81
|
-
|
|
82
|
+
"""
|
|
82
83
|
if contributors is None:
|
|
83
84
|
return []
|
|
84
85
|
return contributors
|
|
85
86
|
|
|
86
|
-
class MitreAttackEnrichment(BaseModel):
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
class MitreAttackEnrichment(BaseModel):
|
|
89
|
+
ConfigDict(extra="forbid")
|
|
89
90
|
mitre_attack_id: MITRE_ATTACK_ID_TYPE = Field(...)
|
|
90
91
|
mitre_attack_technique: str = Field(...)
|
|
91
92
|
mitre_attack_tactics: List[MitreTactics] = Field(...)
|
|
92
93
|
mitre_attack_groups: List[str] = Field(...)
|
|
93
|
-
#Exclude this field from serialization - it is very large and not useful in JSON objects
|
|
94
|
+
# Exclude this field from serialization - it is very large and not useful in JSON objects
|
|
94
95
|
mitre_attack_group_objects: list[MitreAttackGroup] = Field(..., exclude=True)
|
|
96
|
+
|
|
95
97
|
def __hash__(self) -> int:
|
|
96
98
|
return id(self)
|
|
97
|
-
|
|
@@ -14,6 +14,7 @@ class NotableAction(BaseModel):
|
|
|
14
14
|
:param security_domain: the domain associated with the notable action and related rule (detection/search)
|
|
15
15
|
:param severity: severity (e.g. "high") associated with the notable action and related rule (detection/search)
|
|
16
16
|
"""
|
|
17
|
+
|
|
17
18
|
rule_name: str
|
|
18
19
|
rule_description: str
|
|
19
20
|
security_domain: str
|
|
@@ -32,5 +33,5 @@ class NotableAction(BaseModel):
|
|
|
32
33
|
rule_name=dict_["action.notable.param.rule_title"],
|
|
33
34
|
rule_description=dict_["action.notable.param.rule_description"],
|
|
34
35
|
security_domain=dict_["action.notable.param.security_domain"],
|
|
35
|
-
severity=dict_["action.notable.param.severity"]
|
|
36
|
+
severity=dict_["action.notable.param.severity"],
|
|
36
37
|
)
|
|
@@ -13,9 +13,7 @@ class NotableEvent(BaseModel):
|
|
|
13
13
|
|
|
14
14
|
# Allowing fields that aren't explicitly defined to be passed since some of the risk event's
|
|
15
15
|
# fields vary depending on the SPL which generated them
|
|
16
|
-
model_config = ConfigDict(
|
|
17
|
-
extra='allow'
|
|
18
|
-
)
|
|
16
|
+
model_config = ConfigDict(extra="allow")
|
|
19
17
|
|
|
20
18
|
def validate_against_detection(self, detection: Detection) -> None:
|
|
21
19
|
raise NotImplementedError()
|
contentctl/objects/playbook.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from typing import
|
|
2
|
+
from typing import Self
|
|
3
3
|
from pydantic import model_validator, Field, FilePath
|
|
4
4
|
|
|
5
5
|
|
|
@@ -10,57 +10,59 @@ from contentctl.objects.enums import PlaybookType
|
|
|
10
10
|
|
|
11
11
|
class Playbook(SecurityContentObject):
|
|
12
12
|
type: PlaybookType = Field(...)
|
|
13
|
-
|
|
13
|
+
|
|
14
14
|
# Override the type definition for filePath.
|
|
15
15
|
# This MUST be backed by a file and cannot be None
|
|
16
16
|
file_path: FilePath
|
|
17
|
-
|
|
17
|
+
|
|
18
18
|
how_to_implement: str = Field(min_length=4)
|
|
19
19
|
playbook: str = Field(min_length=4)
|
|
20
|
-
app_list: list[str] = Field(...,min_length=0)
|
|
20
|
+
app_list: list[str] = Field(..., min_length=0)
|
|
21
21
|
tags: PlaybookTag = Field(...)
|
|
22
|
-
|
|
23
22
|
|
|
24
|
-
|
|
25
23
|
@model_validator(mode="after")
|
|
26
|
-
def ensureJsonAndPyFilesExist(self)->Self:
|
|
24
|
+
def ensureJsonAndPyFilesExist(self) -> Self:
|
|
27
25
|
json_file_path = self.file_path.with_suffix(".json")
|
|
28
26
|
python_file_path = self.file_path.with_suffix(".py")
|
|
29
|
-
missing:list[str] = []
|
|
27
|
+
missing: list[str] = []
|
|
30
28
|
if not json_file_path.is_file():
|
|
31
|
-
missing.append(
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
missing.append(
|
|
30
|
+
f"Playbook file named '{self.file_path.name}' MUST "
|
|
31
|
+
f"have a .json file named '{json_file_path.name}', "
|
|
32
|
+
"but it does not exist"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
35
|
if not python_file_path.is_file():
|
|
36
|
-
missing.append(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
36
|
+
missing.append(
|
|
37
|
+
f"Playbook file named '{self.file_path.name}' MUST "
|
|
38
|
+
f"have a .py file named '{python_file_path.name}', "
|
|
39
|
+
"but it does not exist"
|
|
40
|
+
)
|
|
41
|
+
|
|
41
42
|
if len(missing) == 0:
|
|
42
43
|
return self
|
|
43
44
|
else:
|
|
44
|
-
missing_files_string =
|
|
45
|
+
missing_files_string = "\n - ".join(missing)
|
|
45
46
|
raise ValueError(f"Playbook files missing:\n -{missing_files_string}")
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
#Override playbook file name checking FOR NOW
|
|
48
|
+
# Override playbook file name checking FOR NOW
|
|
49
49
|
@model_validator(mode="after")
|
|
50
|
-
def ensureFileNameMatchesSearchName(self)->Self:
|
|
51
|
-
file_name =
|
|
52
|
-
.replace(
|
|
53
|
-
.replace(
|
|
54
|
-
.replace(
|
|
55
|
-
.replace(
|
|
56
|
-
.lower()
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (self.file_path is not None and file_name != self.file_path.name.lower()):
|
|
60
|
-
raise ValueError(f"The file name MUST be based off the content 'name' field:\n"\
|
|
61
|
-
f"\t- Expected File Name: {file_name}\n"\
|
|
62
|
-
f"\t- Actual File Name : {self.file_path.name}")
|
|
50
|
+
def ensureFileNameMatchesSearchName(self) -> Self:
|
|
51
|
+
file_name = (
|
|
52
|
+
self.name.replace(" ", "_")
|
|
53
|
+
.replace("-", "_")
|
|
54
|
+
.replace(".", "_")
|
|
55
|
+
.replace("/", "_")
|
|
56
|
+
.lower()
|
|
57
|
+
+ ".yml"
|
|
58
|
+
)
|
|
63
59
|
|
|
64
|
-
|
|
60
|
+
# allow different capitalization FOR NOW in playbook file names
|
|
61
|
+
if self.file_path is not None and file_name != self.file_path.name.lower():
|
|
62
|
+
raise ValueError(
|
|
63
|
+
f"The file name MUST be based off the content 'name' field:\n"
|
|
64
|
+
f"\t- Expected File Name: {file_name}\n"
|
|
65
|
+
f"\t- Actual File Name : {self.file_path.name}"
|
|
66
|
+
)
|
|
65
67
|
|
|
66
|
-
|
|
68
|
+
return self
|
|
@@ -1,26 +1,31 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from typing import
|
|
3
|
-
from pydantic import BaseModel, Field,ConfigDict
|
|
2
|
+
from typing import Optional, List
|
|
3
|
+
from pydantic import BaseModel, Field, ConfigDict
|
|
4
4
|
import enum
|
|
5
5
|
from contentctl.objects.detection import Detection
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
class PlaybookProduct(str,enum.Enum):
|
|
8
|
+
class PlaybookProduct(str, enum.Enum):
|
|
9
9
|
SPLUNK_SOAR = "Splunk SOAR"
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
|
|
12
|
+
class PlaybookUseCase(str, enum.Enum):
|
|
12
13
|
PHISHING = "Phishing"
|
|
13
14
|
ENDPOINT = "Endpoint"
|
|
14
15
|
ENRICHMENT = "Enrichment"
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PlaybookType(str, enum.Enum):
|
|
17
19
|
INPUT = "Input"
|
|
18
20
|
AUTOMATION = "Automation"
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
|
|
23
|
+
class VpeType(str, enum.Enum):
|
|
21
24
|
MODERN = "Modern"
|
|
22
25
|
CLASSIC = "Classic"
|
|
23
|
-
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DefendTechnique(str, enum.Enum):
|
|
24
29
|
D3_AL = "D3-AL"
|
|
25
30
|
D3_DNSDL = "D3-DNSDL"
|
|
26
31
|
D3_DA = "D3-DA"
|
|
@@ -35,20 +40,21 @@ class DefendTechnique(str,enum.Enum):
|
|
|
35
40
|
D3_FHRA = "D3-FHRA"
|
|
36
41
|
D3_SRA = "D3-SRA"
|
|
37
42
|
D3_RUAA = "D3-RUAA"
|
|
43
|
+
|
|
44
|
+
|
|
38
45
|
class PlaybookTag(BaseModel):
|
|
39
46
|
model_config = ConfigDict(extra="forbid")
|
|
40
47
|
analytic_story: Optional[list] = None
|
|
41
48
|
detections: Optional[list] = None
|
|
42
|
-
platform_tags: list[str] = Field(...,min_length=0)
|
|
49
|
+
platform_tags: list[str] = Field(..., min_length=0)
|
|
43
50
|
playbook_type: PlaybookType = Field(...)
|
|
44
51
|
vpe_type: VpeType = Field(...)
|
|
45
52
|
playbook_fields: list[str] = Field([], min_length=0)
|
|
46
|
-
product: list[PlaybookProduct] = Field([],min_length=0)
|
|
47
|
-
use_cases: list[PlaybookUseCase] = Field([],min_length=0)
|
|
53
|
+
product: list[PlaybookProduct] = Field([], min_length=0)
|
|
54
|
+
use_cases: list[PlaybookUseCase] = Field([], min_length=0)
|
|
48
55
|
defend_technique_id: Optional[List[DefendTechnique]] = None
|
|
49
|
-
|
|
50
|
-
labels:list[str] = []
|
|
51
|
-
playbook_outputs:list[str] = []
|
|
52
|
-
|
|
56
|
+
|
|
57
|
+
labels: list[str] = []
|
|
58
|
+
playbook_outputs: list[str] = []
|
|
59
|
+
|
|
53
60
|
detection_objects: list[Detection] = []
|
|
54
|
-
|
contentctl/objects/rba.py
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
|
-
from
|
|
2
|
-
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
3
|
from abc import ABC
|
|
4
|
-
from
|
|
5
|
-
from
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Annotated, Set
|
|
6
6
|
|
|
7
|
+
from pydantic import BaseModel, Field, computed_field, model_serializer
|
|
8
|
+
|
|
9
|
+
from contentctl.objects.enums import RiskSeverity
|
|
7
10
|
|
|
8
11
|
RiskScoreValue_Type = Annotated[int, Field(ge=1, le=100)]
|
|
9
12
|
|
|
13
|
+
|
|
10
14
|
class RiskObjectType(str, Enum):
|
|
11
15
|
SYSTEM = "system"
|
|
12
16
|
USER = "user"
|
|
13
17
|
OTHER = "other"
|
|
14
18
|
|
|
19
|
+
|
|
15
20
|
class ThreatObjectType(str, Enum):
|
|
16
21
|
CERTIFICATE_COMMON_NAME = "certificate_common_name"
|
|
17
22
|
CERTIFICATE_ORGANIZATION = "certificate_organization"
|
|
@@ -40,6 +45,7 @@ class ThreatObjectType(str, Enum):
|
|
|
40
45
|
TLS_HASH = "tls_hash"
|
|
41
46
|
URL = "url"
|
|
42
47
|
|
|
48
|
+
|
|
43
49
|
class RiskObject(BaseModel):
|
|
44
50
|
field: str
|
|
45
51
|
type: RiskObjectType
|
|
@@ -48,6 +54,29 @@ class RiskObject(BaseModel):
|
|
|
48
54
|
def __hash__(self):
|
|
49
55
|
return hash((self.field, self.type, self.score))
|
|
50
56
|
|
|
57
|
+
def __lt__(self, other: RiskObject) -> bool:
|
|
58
|
+
if (
|
|
59
|
+
f"{self.field}{self.type}{self.score}"
|
|
60
|
+
< f"{other.field}{other.type}{other.score}"
|
|
61
|
+
):
|
|
62
|
+
return True
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
@model_serializer
|
|
66
|
+
def serialize_risk_object(self) -> dict[str, str | int]:
|
|
67
|
+
"""
|
|
68
|
+
We define this explicitly for two reasons, even though the automatic
|
|
69
|
+
serialization works correctly. First we want to enforce a specific
|
|
70
|
+
field order for reasons of readability. Second, some of the fields
|
|
71
|
+
actually have different names than they do in the object.
|
|
72
|
+
"""
|
|
73
|
+
return {
|
|
74
|
+
"risk_object_field": self.field,
|
|
75
|
+
"risk_object_type": self.type,
|
|
76
|
+
"risk_score": self.score,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
51
80
|
class ThreatObject(BaseModel):
|
|
52
81
|
field: str
|
|
53
82
|
type: ThreatObjectType
|
|
@@ -55,26 +84,45 @@ class ThreatObject(BaseModel):
|
|
|
55
84
|
def __hash__(self):
|
|
56
85
|
return hash((self.field, self.type))
|
|
57
86
|
|
|
87
|
+
def __lt__(self, other: ThreatObject) -> bool:
|
|
88
|
+
if f"{self.field}{self.type}" < f"{other.field}{other.type}":
|
|
89
|
+
return True
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
@model_serializer
|
|
93
|
+
def serialize_threat_object(self) -> dict[str, str]:
|
|
94
|
+
"""
|
|
95
|
+
We define this explicitly for two reasons, even though the automatic
|
|
96
|
+
serialization works correctly. First we want to enforce a specific
|
|
97
|
+
field order for reasons of readability. Second, some of the fields
|
|
98
|
+
actually have different names than they do in the object.
|
|
99
|
+
"""
|
|
100
|
+
return {
|
|
101
|
+
"threat_object_field": self.field,
|
|
102
|
+
"threat_object_type": self.type,
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
58
106
|
class RBAObject(BaseModel, ABC):
|
|
59
107
|
message: str
|
|
60
108
|
risk_objects: Annotated[Set[RiskObject], Field(min_length=1)]
|
|
61
109
|
threat_objects: Set[ThreatObject]
|
|
62
110
|
|
|
63
|
-
|
|
64
|
-
|
|
65
111
|
@computed_field
|
|
66
112
|
@property
|
|
67
|
-
def risk_score(self)->RiskScoreValue_Type:
|
|
113
|
+
def risk_score(self) -> RiskScoreValue_Type:
|
|
68
114
|
# First get the maximum score associated with
|
|
69
115
|
# a risk object. If there are no objects, then
|
|
70
116
|
# we should throw an exception.
|
|
71
117
|
if len(self.risk_objects) == 0:
|
|
72
|
-
raise Exception(
|
|
118
|
+
raise Exception(
|
|
119
|
+
"There must be at least one Risk Object present to get Severity."
|
|
120
|
+
)
|
|
73
121
|
return max([risk_object.score for risk_object in self.risk_objects])
|
|
74
|
-
|
|
122
|
+
|
|
75
123
|
@computed_field
|
|
76
124
|
@property
|
|
77
|
-
def severity(self)->RiskSeverity:
|
|
125
|
+
def severity(self) -> RiskSeverity:
|
|
78
126
|
if 0 <= self.risk_score <= 20:
|
|
79
127
|
return RiskSeverity.INFORMATIONAL
|
|
80
128
|
elif 20 < self.risk_score <= 40:
|
|
@@ -86,5 +134,14 @@ class RBAObject(BaseModel, ABC):
|
|
|
86
134
|
elif 80 < self.risk_score <= 100:
|
|
87
135
|
return RiskSeverity.CRITICAL
|
|
88
136
|
else:
|
|
89
|
-
raise Exception(
|
|
137
|
+
raise Exception(
|
|
138
|
+
f"Error getting severity - risk_score must be between 0-100, but was actually {self.risk_score}"
|
|
139
|
+
)
|
|
90
140
|
|
|
141
|
+
@model_serializer
|
|
142
|
+
def serialize_rba(self) -> dict[str, str | list[dict[str, str | int]]]:
|
|
143
|
+
return {
|
|
144
|
+
"message": self.message,
|
|
145
|
+
"risk_objects": [obj.model_dump() for obj in sorted(self.risk_objects)],
|
|
146
|
+
"threat_objects": [obj.model_dump() for obj in sorted(self.threat_objects)],
|
|
147
|
+
}
|
|
@@ -18,6 +18,7 @@ class RiskAnalysisAction(BaseModel):
|
|
|
18
18
|
:param message: the message associated w/ the risk event (NOTE: may contain macros of the form
|
|
19
19
|
$...$ which should be replaced with real values in the resulting risk events)
|
|
20
20
|
"""
|
|
21
|
+
|
|
21
22
|
risk_objects: list[RiskObject]
|
|
22
23
|
message: str
|
|
23
24
|
|
|
@@ -80,21 +81,24 @@ class RiskAnalysisAction(BaseModel):
|
|
|
80
81
|
# TODO (#231): add validation ensuring at least 1 risk objects
|
|
81
82
|
for entry in object_dicts:
|
|
82
83
|
if "risk_object_field" in entry:
|
|
83
|
-
risk_objects.append(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
84
|
+
risk_objects.append(
|
|
85
|
+
RiskObject(
|
|
86
|
+
field=entry["risk_object_field"],
|
|
87
|
+
type=entry["risk_object_type"],
|
|
88
|
+
score=int(entry["risk_score"]),
|
|
89
|
+
)
|
|
90
|
+
)
|
|
88
91
|
elif "threat_object_field" in entry:
|
|
89
|
-
threat_objects.append(
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
threat_objects.append(
|
|
93
|
+
ThreatObject(
|
|
94
|
+
field=entry["threat_object_field"],
|
|
95
|
+
type=entry["threat_object_type"],
|
|
96
|
+
)
|
|
97
|
+
)
|
|
93
98
|
else:
|
|
94
99
|
raise ValueError(
|
|
95
100
|
f"Unexpected object within 'action.risk.param._risk': {entry}"
|
|
96
101
|
)
|
|
97
102
|
return cls(
|
|
98
|
-
risk_objects=risk_objects,
|
|
99
|
-
message=dict_["action.risk.param._risk_message"]
|
|
103
|
+
risk_objects=risk_objects, message=dict_["action.risk.param._risk_message"]
|
|
100
104
|
)
|
contentctl/objects/risk_event.py
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
1
|
import re
|
|
2
2
|
from functools import cached_property
|
|
3
3
|
|
|
4
|
-
from pydantic import
|
|
5
|
-
|
|
4
|
+
from pydantic import (
|
|
5
|
+
BaseModel,
|
|
6
|
+
ConfigDict,
|
|
7
|
+
Field,
|
|
8
|
+
PrivateAttr,
|
|
9
|
+
computed_field,
|
|
10
|
+
field_validator,
|
|
11
|
+
)
|
|
12
|
+
|
|
6
13
|
from contentctl.objects.detection import Detection
|
|
14
|
+
from contentctl.objects.errors import ValidationFailed
|
|
7
15
|
from contentctl.objects.rba import RiskObject
|
|
8
16
|
|
|
9
17
|
|
|
@@ -15,11 +23,11 @@ class RiskEvent(BaseModel):
|
|
|
15
23
|
|
|
16
24
|
# The subject of the risk event (e.g. a username, process name, system name, account ID, etc.)
|
|
17
25
|
# (not to be confused w/ the risk object from the detection)
|
|
18
|
-
es_risk_object: int | str
|
|
26
|
+
es_risk_object: int | str = Field(alias="risk_object")
|
|
19
27
|
|
|
20
28
|
# The type of the risk object from ES (e.g. user, system, or other) (not to be confused w/
|
|
21
29
|
# the risk object from the detection)
|
|
22
|
-
es_risk_object_type: str
|
|
30
|
+
es_risk_object_type: str = Field(alias="risk_object_type")
|
|
23
31
|
|
|
24
32
|
# The level of risk associated w/ the risk event
|
|
25
33
|
risk_score: int
|
|
@@ -35,8 +43,7 @@ class RiskEvent(BaseModel):
|
|
|
35
43
|
|
|
36
44
|
# The MITRE ATT&CK IDs
|
|
37
45
|
annotations_mitre_attack: list[str] = Field(
|
|
38
|
-
alias="annotations.mitre_attack",
|
|
39
|
-
default=[]
|
|
46
|
+
alias="annotations.mitre_attack", default=[]
|
|
40
47
|
)
|
|
41
48
|
|
|
42
49
|
# Contributing events search query (we use this to derive the corresponding field from the
|
|
@@ -48,9 +55,7 @@ class RiskEvent(BaseModel):
|
|
|
48
55
|
|
|
49
56
|
# Allowing fields that aren't explicitly defined to be passed since some of the risk event's
|
|
50
57
|
# fields vary depending on the SPL which generated them
|
|
51
|
-
model_config = ConfigDict(
|
|
52
|
-
extra="allow"
|
|
53
|
-
)
|
|
58
|
+
model_config = ConfigDict(extra="allow")
|
|
54
59
|
|
|
55
60
|
@field_validator("annotations_mitre_attack", "analyticstories", mode="before")
|
|
56
61
|
@classmethod
|
|
@@ -72,7 +77,9 @@ class RiskEvent(BaseModel):
|
|
|
72
77
|
event(s). Useful for mapping back to a risk object in the detection.
|
|
73
78
|
"""
|
|
74
79
|
pattern = re.compile(
|
|
75
|
-
r"\| savedsearch \""
|
|
80
|
+
r"\| savedsearch \""
|
|
81
|
+
+ self.search_name
|
|
82
|
+
+ r"\" \| search (?P<field>[^=]+)=.+"
|
|
76
83
|
)
|
|
77
84
|
match = pattern.search(self.contributing_events_search)
|
|
78
85
|
if match is None:
|
|
@@ -121,7 +128,9 @@ class RiskEvent(BaseModel):
|
|
|
121
128
|
:param detection: the detection associated w/ this risk event
|
|
122
129
|
:raises: ValidationFailed
|
|
123
130
|
"""
|
|
124
|
-
if sorted(self.annotations_mitre_attack) != sorted(
|
|
131
|
+
if sorted(self.annotations_mitre_attack) != sorted(
|
|
132
|
+
detection.tags.mitre_attack_id
|
|
133
|
+
):
|
|
125
134
|
raise ValidationFailed(
|
|
126
135
|
f"MITRE ATT&CK IDs in risk event ({self.annotations_mitre_attack}) do not match those"
|
|
127
136
|
f" in detection ({detection.tags.mitre_attack_id})."
|
|
@@ -134,7 +143,9 @@ class RiskEvent(BaseModel):
|
|
|
134
143
|
:raises: ValidationFailed
|
|
135
144
|
"""
|
|
136
145
|
# Render the detection analytic_story to a list of strings before comparing
|
|
137
|
-
detection_analytic_story = [
|
|
146
|
+
detection_analytic_story = [
|
|
147
|
+
story.name for story in detection.tags.analytic_story
|
|
148
|
+
]
|
|
138
149
|
if sorted(self.analyticstories) != sorted(detection_analytic_story):
|
|
139
150
|
raise ValidationFailed(
|
|
140
151
|
f"Analytic stories in risk event ({self.analyticstories}) do not match those"
|
|
@@ -174,16 +185,12 @@ class RiskEvent(BaseModel):
|
|
|
174
185
|
# placeholder
|
|
175
186
|
tmp_placeholder = "PLACEHOLDERPATTERNFORESCAPING"
|
|
176
187
|
escaped_source_message_with_placeholder: str = re.escape(
|
|
177
|
-
field_replacement_pattern.sub(
|
|
178
|
-
tmp_placeholder,
|
|
179
|
-
detection.rba.message
|
|
180
|
-
)
|
|
188
|
+
field_replacement_pattern.sub(tmp_placeholder, detection.rba.message)
|
|
181
189
|
)
|
|
182
190
|
placeholder_replacement_pattern = re.compile(tmp_placeholder)
|
|
183
191
|
final_risk_message_pattern = re.compile(
|
|
184
192
|
placeholder_replacement_pattern.sub(
|
|
185
|
-
r"[\\s\\S]*\\S[\\s\\S]*",
|
|
186
|
-
escaped_source_message_with_placeholder
|
|
193
|
+
r"[\\s\\S]*\\S[\\s\\S]*", escaped_source_message_with_placeholder
|
|
187
194
|
)
|
|
188
195
|
)
|
|
189
196
|
|
|
@@ -191,8 +198,8 @@ class RiskEvent(BaseModel):
|
|
|
191
198
|
if final_risk_message_pattern.match(self.risk_message) is None:
|
|
192
199
|
raise ValidationFailed(
|
|
193
200
|
"Risk message in event does not match the pattern set by the detection. Message in "
|
|
194
|
-
f
|
|
195
|
-
f"
|
|
201
|
+
f'risk event: "{self.risk_message}". Message in detection: '
|
|
202
|
+
f'"{detection.rba.message}".'
|
|
196
203
|
)
|
|
197
204
|
|
|
198
205
|
def validate_risk_against_risk_objects(self, risk_objects: set[RiskObject]) -> None:
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
|
|
2
1
|
from pathlib import Path
|
|
3
2
|
from typing import Any, ClassVar
|
|
4
3
|
import re
|
|
@@ -17,6 +16,7 @@ class SavedsearchesConf(BaseModel):
|
|
|
17
16
|
NOTE: At present, this model only parses the detections themselves from the .conf; thing like
|
|
18
17
|
baselines or response tasks are left alone currently
|
|
19
18
|
"""
|
|
19
|
+
|
|
20
20
|
# The path to the conf file
|
|
21
21
|
path: Path = Field(...)
|
|
22
22
|
|
|
@@ -112,8 +112,7 @@ class SavedsearchesConf(BaseModel):
|
|
|
112
112
|
|
|
113
113
|
# Build the stanza model from the accumulated lines and adjust the state to end this section
|
|
114
114
|
self.detection_stanzas[self._current_section_name] = DetectionStanza(
|
|
115
|
-
name=self._current_section_name,
|
|
116
|
-
lines=self._current_section_lines
|
|
115
|
+
name=self._current_section_name, lines=self._current_section_lines
|
|
117
116
|
)
|
|
118
117
|
self._in_section = False
|
|
119
118
|
|
|
@@ -170,7 +169,9 @@ class SavedsearchesConf(BaseModel):
|
|
|
170
169
|
self._in_detections = True
|
|
171
170
|
|
|
172
171
|
@staticmethod
|
|
173
|
-
def init_from_package(
|
|
172
|
+
def init_from_package(
|
|
173
|
+
package_path: Path, app_name: str, appid: str
|
|
174
|
+
) -> "SavedsearchesConf":
|
|
174
175
|
"""
|
|
175
176
|
Alternate constructor which can take an app package, and extract the savedsearches.conf from
|
|
176
177
|
a temporary file.
|
|
@@ -188,9 +189,10 @@ class SavedsearchesConf(BaseModel):
|
|
|
188
189
|
# Open the tar/gzip archive
|
|
189
190
|
with tarfile.open(package_path) as package:
|
|
190
191
|
# Extract the savedsearches.conf and use it to init the model
|
|
191
|
-
package_conf_path = SavedsearchesConf.PACKAGE_CONF_PATH_FMT_STR.format(
|
|
192
|
+
package_conf_path = SavedsearchesConf.PACKAGE_CONF_PATH_FMT_STR.format(
|
|
193
|
+
appid=appid
|
|
194
|
+
)
|
|
192
195
|
package.extract(package_conf_path, path=tmpdir)
|
|
193
196
|
return SavedsearchesConf(
|
|
194
|
-
path=Path(tmpdir, package_conf_path),
|
|
195
|
-
app_label=app_name
|
|
197
|
+
path=Path(tmpdir, package_conf_path), app_label=app_name
|
|
196
198
|
)
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from contentctl.objects.abstract_security_content_objects.security_content_object_abstract import
|
|
2
|
+
from contentctl.objects.abstract_security_content_objects.security_content_object_abstract import (
|
|
3
|
+
SecurityContentObject_Abstract,
|
|
4
|
+
)
|
|
5
|
+
|
|
3
6
|
|
|
4
7
|
class SecurityContentObject(SecurityContentObject_Abstract):
|
|
5
|
-
pass
|
|
8
|
+
pass
|