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/story.py
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
-
from typing import TYPE_CHECKING,List
|
|
2
|
+
from typing import TYPE_CHECKING, List
|
|
3
3
|
from contentctl.objects.story_tags import StoryTags
|
|
4
|
-
from pydantic import Field, model_serializer,computed_field, model_validator
|
|
4
|
+
from pydantic import Field, model_serializer, computed_field, model_validator
|
|
5
5
|
import re
|
|
6
|
+
|
|
6
7
|
if TYPE_CHECKING:
|
|
7
8
|
from contentctl.objects.detection import Detection
|
|
8
9
|
from contentctl.objects.investigation import Investigation
|
|
@@ -12,88 +13,90 @@ if TYPE_CHECKING:
|
|
|
12
13
|
|
|
13
14
|
from contentctl.objects.security_content_object import SecurityContentObject
|
|
14
15
|
|
|
16
|
+
|
|
15
17
|
class Story(SecurityContentObject):
|
|
16
18
|
narrative: str = Field(...)
|
|
17
19
|
tags: StoryTags = Field(...)
|
|
18
20
|
|
|
19
21
|
# These are updated when detection and investigation objects are created.
|
|
20
22
|
# Specifically in the model_post_init functions
|
|
21
|
-
detections:List[Detection] = []
|
|
23
|
+
detections: List[Detection] = []
|
|
22
24
|
investigations: List[Investigation] = []
|
|
23
25
|
baselines: List[Baseline] = []
|
|
24
|
-
|
|
25
|
-
|
|
26
|
+
|
|
26
27
|
@computed_field
|
|
27
28
|
@property
|
|
28
|
-
def data_sources(self)-> list[DataSource]:
|
|
29
|
+
def data_sources(self) -> list[DataSource]:
|
|
29
30
|
# Only add a data_source if it does not already exist in the story
|
|
30
|
-
data_source_objects:set[DataSource] = set()
|
|
31
|
+
data_source_objects: set[DataSource] = set()
|
|
31
32
|
for detection in self.detections:
|
|
32
33
|
data_source_objects.update(set(detection.data_source_objects))
|
|
33
|
-
|
|
34
|
+
|
|
34
35
|
return sorted(list(data_source_objects))
|
|
35
36
|
|
|
37
|
+
def storyAndInvestigationNamesWithApp(self, app: CustomApp) -> List[str]:
|
|
38
|
+
return [
|
|
39
|
+
detection.get_conf_stanza_name(app) for detection in self.detections
|
|
40
|
+
] + [
|
|
41
|
+
investigation.get_response_task_name(app)
|
|
42
|
+
for investigation in self.investigations
|
|
43
|
+
]
|
|
36
44
|
|
|
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]
|
|
40
|
-
|
|
41
45
|
@model_serializer
|
|
42
46
|
def serialize_model(self):
|
|
43
|
-
#Call serializer for parent
|
|
47
|
+
# Call serializer for parent
|
|
44
48
|
super_fields = super().serialize_model()
|
|
45
|
-
|
|
46
|
-
#All fields custom to this model
|
|
47
|
-
model= {
|
|
49
|
+
|
|
50
|
+
# All fields custom to this model
|
|
51
|
+
model = {
|
|
48
52
|
"narrative": self.narrative,
|
|
49
53
|
"tags": self.tags.model_dump(),
|
|
50
54
|
"detection_names": self.detection_names,
|
|
51
55
|
"investigation_names": self.investigation_names,
|
|
52
56
|
"baseline_names": self.baseline_names,
|
|
53
57
|
"author_company": self.author_company,
|
|
54
|
-
"author_name":self.author_name
|
|
58
|
+
"author_name": self.author_name,
|
|
55
59
|
}
|
|
56
60
|
detections = []
|
|
57
61
|
for detection in self.detections:
|
|
58
62
|
new_detection = {
|
|
59
|
-
"name":detection.name,
|
|
60
|
-
"source":detection.source,
|
|
61
|
-
"type":detection.type
|
|
63
|
+
"name": detection.name,
|
|
64
|
+
"source": detection.source,
|
|
65
|
+
"type": detection.type,
|
|
62
66
|
}
|
|
63
67
|
if self.tags.mitre_attack_enrichments is not None:
|
|
64
|
-
new_detection[
|
|
68
|
+
new_detection["tags"] = {
|
|
69
|
+
"mitre_attack_enrichments": [
|
|
70
|
+
{"mitre_attack_technique": enrichment.mitre_attack_technique}
|
|
71
|
+
for enrichment in detection.tags.mitre_attack_enrichments
|
|
72
|
+
]
|
|
73
|
+
}
|
|
65
74
|
detections.append(new_detection)
|
|
66
75
|
|
|
67
|
-
model[
|
|
68
|
-
#Combine fields from this model with fields from parent
|
|
76
|
+
model["detections"] = detections
|
|
77
|
+
# Combine fields from this model with fields from parent
|
|
69
78
|
super_fields.update(model)
|
|
70
|
-
|
|
71
|
-
#return the model
|
|
79
|
+
|
|
80
|
+
# return the model
|
|
72
81
|
return super_fields
|
|
73
82
|
|
|
74
83
|
@model_validator(mode="after")
|
|
75
84
|
def setTagsFields(self):
|
|
76
|
-
|
|
77
85
|
enrichments = []
|
|
78
86
|
for detection in self.detections:
|
|
79
87
|
enrichments.extend(detection.tags.mitre_attack_enrichments)
|
|
80
88
|
self.tags.mitre_attack_enrichments = list(set(enrichments))
|
|
81
89
|
|
|
82
|
-
|
|
83
90
|
tactics = []
|
|
84
91
|
for enrichment in self.tags.mitre_attack_enrichments:
|
|
85
92
|
tactics.extend(enrichment.mitre_attack_tactics)
|
|
86
93
|
self.tags.mitre_attack_tactics = set(tactics)
|
|
87
94
|
|
|
88
|
-
|
|
89
|
-
|
|
90
95
|
datamodels = []
|
|
91
96
|
for detection in self.detections:
|
|
92
97
|
datamodels.extend(detection.datamodel)
|
|
93
98
|
self.tags.datamodels = set(datamodels)
|
|
94
99
|
|
|
95
|
-
|
|
96
|
-
|
|
97
100
|
kill_chain_phases = []
|
|
98
101
|
for detection in self.detections:
|
|
99
102
|
kill_chain_phases.extend(detection.tags.kill_chain_phases)
|
|
@@ -101,42 +104,40 @@ class Story(SecurityContentObject):
|
|
|
101
104
|
|
|
102
105
|
return self
|
|
103
106
|
|
|
104
|
-
|
|
105
107
|
@computed_field
|
|
106
108
|
@property
|
|
107
|
-
def author_name(self)->str:
|
|
108
|
-
match_author = re.search(r
|
|
109
|
+
def author_name(self) -> str:
|
|
110
|
+
match_author = re.search(r"^([^,]+)", self.author)
|
|
109
111
|
if match_author is None:
|
|
110
|
-
return
|
|
112
|
+
return "no"
|
|
111
113
|
else:
|
|
112
114
|
return match_author.group(1)
|
|
113
115
|
|
|
114
116
|
@computed_field
|
|
115
117
|
@property
|
|
116
|
-
def author_company(self)->str:
|
|
117
|
-
match_company = re.search(r
|
|
118
|
+
def author_company(self) -> str:
|
|
119
|
+
match_company = re.search(r",\s?(.*)$", self.author)
|
|
118
120
|
if match_company is None:
|
|
119
|
-
return
|
|
121
|
+
return "no"
|
|
120
122
|
else:
|
|
121
123
|
return match_company.group(1)
|
|
122
124
|
|
|
123
125
|
@computed_field
|
|
124
126
|
@property
|
|
125
|
-
def author_email(self)->str:
|
|
127
|
+
def author_email(self) -> str:
|
|
126
128
|
return "-"
|
|
127
129
|
|
|
128
130
|
@computed_field
|
|
129
131
|
@property
|
|
130
|
-
def detection_names(self)->List[str]:
|
|
132
|
+
def detection_names(self) -> List[str]:
|
|
131
133
|
return [detection.name for detection in self.detections]
|
|
132
|
-
|
|
134
|
+
|
|
133
135
|
@computed_field
|
|
134
136
|
@property
|
|
135
|
-
def investigation_names(self)->List[str]:
|
|
137
|
+
def investigation_names(self) -> List[str]:
|
|
136
138
|
return [investigation.name for investigation in self.investigations]
|
|
137
139
|
|
|
138
140
|
@computed_field
|
|
139
141
|
@property
|
|
140
|
-
def baseline_names(self)->List[str]:
|
|
142
|
+
def baseline_names(self) -> List[str]:
|
|
141
143
|
return [baseline.name for baseline in self.baselines]
|
|
142
|
-
|
contentctl/objects/story_tags.py
CHANGED
|
@@ -1,54 +1,66 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
from pydantic import BaseModel, Field, model_serializer, ConfigDict
|
|
3
|
-
from typing import List,Set,Optional
|
|
3
|
+
from typing import List, Set, Optional
|
|
4
4
|
|
|
5
5
|
from enum import Enum
|
|
6
6
|
|
|
7
7
|
from contentctl.objects.mitre_attack_enrichment import MitreAttackEnrichment
|
|
8
|
-
from contentctl.objects.enums import
|
|
9
|
-
|
|
8
|
+
from contentctl.objects.enums import (
|
|
9
|
+
StoryCategory,
|
|
10
|
+
DataModel,
|
|
11
|
+
KillChainPhase,
|
|
12
|
+
SecurityContentProductName,
|
|
13
|
+
)
|
|
14
|
+
from contentctl.objects.annotated_types import CVE_TYPE, MITRE_ATTACK_ID_TYPE
|
|
10
15
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
|
|
17
|
+
class StoryUseCase(str, Enum):
|
|
18
|
+
FRAUD_DETECTION = "Fraud Detection"
|
|
19
|
+
COMPLIANCE = "Compliance"
|
|
20
|
+
APPLICATION_SECURITY = "Application Security"
|
|
21
|
+
SECURITY_MONITORING = "Security Monitoring"
|
|
22
|
+
ADVANCED_THREAD_DETECTION = "Advanced Threat Detection"
|
|
23
|
+
INSIDER_THREAT = "Insider Threat"
|
|
24
|
+
OTHER = "Other"
|
|
19
25
|
|
|
20
26
|
|
|
21
27
|
class StoryTags(BaseModel):
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
28
|
+
model_config = ConfigDict(extra="forbid")
|
|
29
|
+
category: List[StoryCategory] = Field(..., min_length=1)
|
|
30
|
+
product: List[SecurityContentProductName] = Field(..., min_length=1)
|
|
31
|
+
usecase: StoryUseCase = Field(...)
|
|
32
|
+
|
|
33
|
+
# enrichment
|
|
34
|
+
mitre_attack_enrichments: Optional[List[MitreAttackEnrichment]] = None
|
|
35
|
+
mitre_attack_tactics: Optional[Set[MITRE_ATTACK_ID_TYPE]] = None
|
|
36
|
+
datamodels: Optional[Set[DataModel]] = None
|
|
37
|
+
kill_chain_phases: Optional[Set[KillChainPhase]] = None
|
|
38
|
+
cve: List[CVE_TYPE] = []
|
|
39
|
+
group: List[str] = Field(
|
|
40
|
+
[],
|
|
41
|
+
description="A list of groups who leverage the techniques list in this Analytic Story.",
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
def getCategory_conf(self) -> str:
|
|
45
|
+
# if len(self.category) > 1:
|
|
46
|
+
# print("Story with more than 1 category. We can only have 1 category, fix it!")
|
|
47
|
+
return list(self.category)[0]
|
|
48
|
+
|
|
49
|
+
@model_serializer
|
|
50
|
+
def serialize_model(self):
|
|
51
|
+
# no super to call
|
|
52
|
+
return {
|
|
53
|
+
"category": list(self.category),
|
|
54
|
+
"product": list(self.product),
|
|
55
|
+
"usecase": self.usecase,
|
|
56
|
+
"mitre_attack_enrichments": self.mitre_attack_enrichments,
|
|
57
|
+
"mitre_attack_tactics": list(self.mitre_attack_tactics)
|
|
58
|
+
if self.mitre_attack_tactics is not None
|
|
59
|
+
else None,
|
|
60
|
+
"datamodels": list(self.datamodels)
|
|
61
|
+
if self.datamodels is not None
|
|
62
|
+
else None,
|
|
63
|
+
"kill_chain_phases": list(self.kill_chain_phases)
|
|
64
|
+
if self.kill_chain_phases is not None
|
|
65
|
+
else None,
|
|
66
|
+
}
|
contentctl/objects/test_group.py
CHANGED
|
@@ -15,13 +15,16 @@ class TestGroup(BaseModel):
|
|
|
15
15
|
:param integration_test: an IntegrationTest
|
|
16
16
|
:param attack_data: the attack data associated with tests in the TestGroup
|
|
17
17
|
"""
|
|
18
|
+
|
|
18
19
|
name: str
|
|
19
20
|
unit_test: UnitTest
|
|
20
21
|
integration_test: IntegrationTest
|
|
21
22
|
attack_data: list[TestAttackData]
|
|
22
23
|
|
|
23
24
|
@classmethod
|
|
24
|
-
def derive_from_unit_test(
|
|
25
|
+
def derive_from_unit_test(
|
|
26
|
+
cls, unit_test: UnitTest, name_prefix: str
|
|
27
|
+
) -> "TestGroup":
|
|
25
28
|
"""
|
|
26
29
|
Given a UnitTest and a prefix, construct a TestGroup, with in IntegrationTest corresponding to the UnitTest
|
|
27
30
|
:param unit_test: the UnitTest
|
|
@@ -36,7 +39,7 @@ class TestGroup(BaseModel):
|
|
|
36
39
|
name=f"{name_prefix}:{unit_test.name}",
|
|
37
40
|
unit_test=unit_test,
|
|
38
41
|
integration_test=integration_test,
|
|
39
|
-
attack_data=unit_test.attack_data
|
|
42
|
+
attack_data=unit_test.attack_data,
|
|
40
43
|
)
|
|
41
44
|
|
|
42
45
|
def unit_test_skipped(self) -> bool:
|
contentctl/objects/throttling.py
CHANGED
|
@@ -2,25 +2,34 @@ from pydantic import BaseModel, Field, field_validator
|
|
|
2
2
|
from typing import Annotated
|
|
3
3
|
|
|
4
4
|
|
|
5
|
-
# Alert Suppression/Throttling settings have been taken from
|
|
5
|
+
# Alert Suppression/Throttling settings have been taken from
|
|
6
6
|
# https://docs.splunk.com/Documentation/Splunk/9.2.2/Admin/Savedsearchesconf
|
|
7
7
|
class Throttling(BaseModel):
|
|
8
|
-
fields: list[str] = Field(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
fields: list[str] = Field(
|
|
9
|
+
...,
|
|
10
|
+
description="The list of fields to throttle on. These fields MUST occur in the search.",
|
|
11
|
+
min_length=1,
|
|
12
|
+
)
|
|
13
|
+
period: Annotated[str, Field(pattern="^[0-9]+[smh]$")] = Field(
|
|
14
|
+
...,
|
|
15
|
+
description="How often the alert should be triggered. "
|
|
16
|
+
"This may be specified in seconds, minutes, or hours. "
|
|
17
|
+
"For example, if an alert should be triggered once a day,"
|
|
18
|
+
" it may be specified in seconds (86400s), minutes (1440m), or hours import (24h).",
|
|
19
|
+
)
|
|
20
|
+
|
|
14
21
|
@field_validator("fields")
|
|
15
|
-
def no_spaces_in_fields(cls, v:list[str])->list[str]:
|
|
22
|
+
def no_spaces_in_fields(cls, v: list[str]) -> list[str]:
|
|
16
23
|
for field in v:
|
|
17
|
-
if
|
|
18
|
-
raise ValueError(
|
|
19
|
-
|
|
24
|
+
if " " in field:
|
|
25
|
+
raise ValueError(
|
|
26
|
+
"Spaces are not presently supported in 'alert.suppress.fields' / throttling fields in conf files. "
|
|
27
|
+
"The field '{field}' has a space in it. If this is a blocker, please raise this as an issue on the Project."
|
|
28
|
+
)
|
|
20
29
|
return v
|
|
21
30
|
|
|
22
|
-
def conf_formatted_fields(self)->str:
|
|
23
|
-
|
|
31
|
+
def conf_formatted_fields(self) -> str:
|
|
32
|
+
"""
|
|
24
33
|
TODO:
|
|
25
34
|
The field alert.suppress.fields is defined as follows:
|
|
26
35
|
alert.suppress.fields = <comma-delimited-field-list>
|
|
@@ -28,19 +37,19 @@ class Throttling(BaseModel):
|
|
|
28
37
|
be specified if the digest mode is disabled and suppression is enabled.
|
|
29
38
|
|
|
30
39
|
In order to support fields with spaces in them, we may need to wrap each
|
|
31
|
-
field in "".
|
|
40
|
+
field in "".
|
|
32
41
|
This function returns a properly formatted value, where each field
|
|
33
|
-
is wrapped in "" and separated with a comma. For example, the fields
|
|
42
|
+
is wrapped in "" and separated with a comma. For example, the fields
|
|
34
43
|
["field1", "field 2", "field3"] would be returned as the string
|
|
35
44
|
|
|
36
45
|
"field1","field 2","field3
|
|
37
46
|
|
|
38
47
|
However, for now, we will error on fields with spaces and simply
|
|
39
48
|
separate with commas
|
|
40
|
-
|
|
41
|
-
|
|
49
|
+
"""
|
|
50
|
+
|
|
42
51
|
return ",".join(self.fields)
|
|
43
52
|
|
|
44
53
|
# The following may be used once we determine proper support
|
|
45
54
|
# for fields with spaces
|
|
46
|
-
#return ",".join([f'"{field}"' for field in self.fields])
|
|
55
|
+
# return ",".join([f'"{field}"' for field in self.fields])
|
contentctl/objects/unit_test.py
CHANGED
|
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
from pydantic import Field
|
|
4
4
|
|
|
5
|
-
from contentctl.objects.unit_test_baseline import UnitTestBaseline
|
|
6
5
|
from contentctl.objects.test_attack_data import TestAttackData
|
|
7
6
|
from contentctl.objects.unit_test_result import UnitTestResult
|
|
8
7
|
from contentctl.objects.base_test import BaseTest, TestType
|
|
@@ -13,6 +12,7 @@ class UnitTest(BaseTest):
|
|
|
13
12
|
"""
|
|
14
13
|
A unit test for a detection
|
|
15
14
|
"""
|
|
15
|
+
|
|
16
16
|
# contentType: SecurityContentType = SecurityContentType.unit_tests
|
|
17
17
|
|
|
18
18
|
# The test type (unit)
|
|
@@ -29,7 +29,6 @@ class UnitTest(BaseTest):
|
|
|
29
29
|
Skip the test by setting its result status
|
|
30
30
|
:param message: the reason for skipping
|
|
31
31
|
"""
|
|
32
|
-
self.result = UnitTestResult(
|
|
33
|
-
message=message,
|
|
34
|
-
status=TestResultStatus.SKIP
|
|
32
|
+
self.result = UnitTestResult( # type: ignore
|
|
33
|
+
message=message, status=TestResultStatus.SKIP
|
|
35
34
|
)
|
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
from pydantic import BaseModel,ConfigDict
|
|
1
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
2
|
from typing import Union
|
|
5
3
|
|
|
4
|
+
|
|
6
5
|
class UnitTestBaseline(BaseModel):
|
|
7
6
|
model_config = ConfigDict(extra="forbid")
|
|
8
7
|
name: str
|
|
9
8
|
file: str
|
|
10
9
|
pass_condition: str
|
|
11
|
-
earliest_time: Union[str,None] = None
|
|
12
|
-
latest_time: Union[str,None] = None
|
|
10
|
+
earliest_time: Union[str, None] = None
|
|
11
|
+
latest_time: Union[str, None] = None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
-
from typing import Union,TYPE_CHECKING
|
|
3
|
+
from typing import Union, TYPE_CHECKING
|
|
4
4
|
from splunklib.data import Record
|
|
5
5
|
from contentctl.objects.base_test_result import BaseTestResult, TestResultStatus
|
|
6
6
|
|
|
@@ -15,7 +15,7 @@ SID_TEMPLATE = "{server}:{web_port}/en-US/app/search/search?sid={sid}"
|
|
|
15
15
|
|
|
16
16
|
class UnitTestResult(BaseTestResult):
|
|
17
17
|
missing_observables: list[str] = []
|
|
18
|
-
|
|
18
|
+
|
|
19
19
|
def set_job_content(
|
|
20
20
|
self,
|
|
21
21
|
content: Union[Record, None],
|
|
@@ -40,7 +40,7 @@ class UnitTestResult(BaseTestResult):
|
|
|
40
40
|
self.exception = exception
|
|
41
41
|
self.status = status
|
|
42
42
|
self.job_content = content
|
|
43
|
-
|
|
43
|
+
|
|
44
44
|
# Set the job content, if given
|
|
45
45
|
if content is not None:
|
|
46
46
|
if self.status == TestResultStatus.PASS:
|
|
@@ -50,7 +50,7 @@ class UnitTestResult(BaseTestResult):
|
|
|
50
50
|
elif self.status == TestResultStatus.ERROR:
|
|
51
51
|
self.message = "TEST ERROR"
|
|
52
52
|
elif self.status == TestResultStatus.SKIP:
|
|
53
|
-
#A test that was SKIPPED should not have job content since it should not have been run.
|
|
53
|
+
# A test that was SKIPPED should not have job content since it should not have been run.
|
|
54
54
|
self.message = "TEST SKIPPED"
|
|
55
55
|
|
|
56
56
|
if not config.instance_address.startswith("http://"):
|
|
@@ -64,7 +64,7 @@ class UnitTestResult(BaseTestResult):
|
|
|
64
64
|
)
|
|
65
65
|
|
|
66
66
|
elif self.status == TestResultStatus.SKIP:
|
|
67
|
-
self.message = "TEST SKIPPED"
|
|
67
|
+
self.message = "TEST SKIPPED"
|
|
68
68
|
pass
|
|
69
69
|
|
|
70
70
|
elif content is None:
|
|
@@ -72,7 +72,7 @@ class UnitTestResult(BaseTestResult):
|
|
|
72
72
|
if self.exception is not None:
|
|
73
73
|
self.message = f"EXCEPTION: {str(self.exception)}"
|
|
74
74
|
else:
|
|
75
|
-
self.message =
|
|
75
|
+
self.message = "ERROR with no more specific message available."
|
|
76
76
|
self.sid_link = NO_SID
|
|
77
77
|
|
|
78
78
|
return self.success
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
from typing import TYPE_CHECKING
|
|
3
|
+
|
|
3
4
|
if TYPE_CHECKING:
|
|
4
5
|
from contentctl.objects.detection import Detection
|
|
5
6
|
from contentctl.objects.lookup import Lookup
|
|
@@ -15,12 +16,11 @@ import pathlib
|
|
|
15
16
|
from contentctl.output.json_writer import JsonWriter
|
|
16
17
|
|
|
17
18
|
|
|
18
|
-
|
|
19
19
|
class ApiJsonOutput:
|
|
20
20
|
output_path: pathlib.Path
|
|
21
21
|
app_label: str
|
|
22
22
|
|
|
23
|
-
def __init__(self, output_path:pathlib.Path, app_label: str):
|
|
23
|
+
def __init__(self, output_path: pathlib.Path, app_label: str):
|
|
24
24
|
self.output_path = output_path
|
|
25
25
|
self.app_label = app_label
|
|
26
26
|
|
|
@@ -53,7 +53,7 @@ class ApiJsonOutput:
|
|
|
53
53
|
)
|
|
54
54
|
for detection in objects
|
|
55
55
|
]
|
|
56
|
-
#Only a subset of macro fields are required:
|
|
56
|
+
# Only a subset of macro fields are required:
|
|
57
57
|
# for detection in detections:
|
|
58
58
|
# new_macros = []
|
|
59
59
|
# for macro in detection.get("macros",[]):
|
|
@@ -62,16 +62,15 @@ class ApiJsonOutput:
|
|
|
62
62
|
# new_macro_fields["definition"] = macro.get("definition")
|
|
63
63
|
# new_macro_fields["description"] = macro.get("description")
|
|
64
64
|
# if len(macro.get("arguments", [])) > 0:
|
|
65
|
-
# new_macro_fields["arguments"] = macro.get("arguments")
|
|
65
|
+
# new_macro_fields["arguments"] = macro.get("arguments")
|
|
66
66
|
# new_macros.append(new_macro_fields)
|
|
67
67
|
# detection["macros"] = new_macros
|
|
68
68
|
# del()
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
|
|
71
70
|
JsonWriter.writeJsonObject(
|
|
72
71
|
os.path.join(self.output_path, "detections.json"), "detections", detections
|
|
73
72
|
)
|
|
74
|
-
|
|
73
|
+
|
|
75
74
|
def writeMacros(
|
|
76
75
|
self,
|
|
77
76
|
objects: list[Macro],
|
|
@@ -81,13 +80,13 @@ class ApiJsonOutput:
|
|
|
81
80
|
for macro in objects
|
|
82
81
|
]
|
|
83
82
|
for macro in macros:
|
|
84
|
-
for k in ["author", "date","version","id","references"]:
|
|
83
|
+
for k in ["author", "date", "version", "id", "references"]:
|
|
85
84
|
if k in macro:
|
|
86
|
-
del
|
|
85
|
+
del macro[k]
|
|
87
86
|
JsonWriter.writeJsonObject(
|
|
88
87
|
os.path.join(self.output_path, "macros.json"), "macros", macros
|
|
89
88
|
)
|
|
90
|
-
|
|
89
|
+
|
|
91
90
|
def writeStories(
|
|
92
91
|
self,
|
|
93
92
|
objects: list[Story],
|
|
@@ -126,8 +125,9 @@ class ApiJsonOutput:
|
|
|
126
125
|
}
|
|
127
126
|
for detection in story["detections"]
|
|
128
127
|
]
|
|
129
|
-
story["detection_names"] = [
|
|
130
|
-
|
|
128
|
+
story["detection_names"] = [
|
|
129
|
+
f"{self.app_label} - {name} - Rule" for name in story["detection_names"]
|
|
130
|
+
]
|
|
131
131
|
|
|
132
132
|
JsonWriter.writeJsonObject(
|
|
133
133
|
os.path.join(self.output_path, "stories.json"), "stories", stories
|
|
@@ -159,10 +159,10 @@ class ApiJsonOutput:
|
|
|
159
159
|
)
|
|
160
160
|
for baseline in objects
|
|
161
161
|
]
|
|
162
|
-
|
|
162
|
+
|
|
163
163
|
JsonWriter.writeJsonObject(
|
|
164
|
-
|
|
165
|
-
|
|
164
|
+
os.path.join(self.output_path, "baselines.json"), "baselines", baselines
|
|
165
|
+
)
|
|
166
166
|
|
|
167
167
|
def writeInvestigations(
|
|
168
168
|
self,
|
|
@@ -221,9 +221,9 @@ class ApiJsonOutput:
|
|
|
221
221
|
for lookup in objects
|
|
222
222
|
]
|
|
223
223
|
for lookup in lookups:
|
|
224
|
-
for k in ["author","date","version","id","references"]:
|
|
224
|
+
for k in ["author", "date", "version", "id", "references"]:
|
|
225
225
|
if k in lookup:
|
|
226
|
-
del
|
|
226
|
+
del lookup[k]
|
|
227
227
|
JsonWriter.writeJsonObject(
|
|
228
228
|
os.path.join(self.output_path, "lookups.json"), "lookups", lookups
|
|
229
229
|
)
|
|
@@ -244,16 +244,16 @@ class ApiJsonOutput:
|
|
|
244
244
|
"description",
|
|
245
245
|
"scheduling",
|
|
246
246
|
"rba",
|
|
247
|
-
"tags"
|
|
248
|
-
]
|
|
247
|
+
"tags",
|
|
248
|
+
]
|
|
249
249
|
)
|
|
250
250
|
)
|
|
251
251
|
for deployment in objects
|
|
252
252
|
]
|
|
253
|
-
#references are not to be included, but have been deleted in the
|
|
254
|
-
#model_serialization logic
|
|
253
|
+
# references are not to be included, but have been deleted in the
|
|
254
|
+
# model_serialization logic
|
|
255
255
|
JsonWriter.writeJsonObject(
|
|
256
256
|
os.path.join(self.output_path, "deployments.json"),
|
|
257
257
|
"deployments",
|
|
258
258
|
deployments,
|
|
259
|
-
)
|
|
259
|
+
)
|