Skip to content

Commit 506bbaf

Browse files
authored
Merge branch 'main' into add_detection_type_list
2 parents 7d9d128 + a199c72 commit 506bbaf

33 files changed

+465
-180
lines changed

contentctl/actions/build.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ def execute(self, input_dto: BuildInputDto) -> DirectorOutputDto:
5050
updated_conf_files.update(conf_output.writeObjects(input_dto.director_output_dto.investigations, SecurityContentType.investigations))
5151
updated_conf_files.update(conf_output.writeObjects(input_dto.director_output_dto.lookups, SecurityContentType.lookups))
5252
updated_conf_files.update(conf_output.writeObjects(input_dto.director_output_dto.macros, SecurityContentType.macros))
53+
updated_conf_files.update(conf_output.writeObjects(input_dto.director_output_dto.dashboards, SecurityContentType.dashboards))
5354
updated_conf_files.update(conf_output.writeAppConf())
5455

5556
#Ensure that the conf file we just generated/update is syntactically valid

contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,17 +269,25 @@ def configure_imported_roles(
269269
):
270270
indexes.append(self.sync_obj.replay_index)
271271
indexes_encoded = ";".join(indexes)
272+
272273
try:
274+
# Set which roles should be configured. For Enterprise Security/Integration Testing,
275+
# we must add some extra foles.
276+
if self.global_config.enable_integration_testing:
277+
roles = imported_roles + enterprise_security_roles
278+
else:
279+
roles = imported_roles
280+
273281
self.get_conn().roles.post(
274282
self.infrastructure.splunk_app_username,
275-
imported_roles=imported_roles + enterprise_security_roles,
283+
imported_roles=roles,
276284
srchIndexesAllowed=indexes_encoded,
277285
srchIndexesDefault=self.sync_obj.replay_index,
278286
)
279287
return
280288
except Exception as e:
281289
self.pbar.write(
282-
f"Enterprise Security Roles do not exist:'{enterprise_security_roles}: {str(e)}"
290+
f"The following role(s) do not exist:'{enterprise_security_roles}: {str(e)}"
283291
)
284292

285293
self.get_conn().roles.post(

contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,17 @@ def get_docker_client(self):
4949
def check_for_teardown(self):
5050

5151
try:
52-
self.get_docker_client().containers.get(self.get_name())
52+
container: docker.models.containers.Container = self.get_docker_client().containers.get(self.get_name())
5353
except Exception as e:
5454
if self.sync_obj.terminate is not True:
5555
self.pbar.write(
5656
f"Error: could not get container [{self.get_name()}]: {str(e)}"
5757
)
5858
self.sync_obj.terminate = True
59+
else:
60+
if container.status != 'running':
61+
self.sync_obj.terminate = True
62+
self.container = None
5963

6064
if self.sync_obj.terminate:
6165
self.finish()

contentctl/actions/new_content.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ class NewContent:
1616

1717
def buildDetection(self)->dict[str,Any]:
1818
questions = NewContentQuestions.get_questions_detection()
19-
answers = questionary.prompt(questions)
19+
answers: dict[str,str] = questionary.prompt(
20+
questions,
21+
kbi_msg="User did not answer all of the prompt questions. Exiting...")
22+
if not answers:
23+
raise ValueError("User didn't answer one or more questions!")
2024
answers.update(answers)
2125
answers['name'] = answers['detection_name']
2226
del answers['detection_name']
@@ -70,7 +74,11 @@ def buildDetection(self)->dict[str,Any]:
7074

7175
def buildStory(self)->dict[str,Any]:
7276
questions = NewContentQuestions.get_questions_story()
73-
answers = questionary.prompt(questions)
77+
answers = questionary.prompt(
78+
questions,
79+
kbi_msg="User did not answer all of the prompt questions. Exiting...")
80+
if not answers:
81+
raise ValueError("User didn't answer one or more questions!")
7482
answers['name'] = answers['story_name']
7583
del answers['story_name']
7684
answers['id'] = str(uuid.uuid4())

contentctl/actions/validate.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313

1414
class Validate:
15-
def execute(self, input_dto: validate) -> DirectorOutputDto:
15+
def execute(self, input_dto: validate) -> DirectorOutputDto:
1616
director_output_dto = DirectorOutputDto(
1717
AtomicEnrichment.getAtomicEnrichment(input_dto),
1818
AttackEnrichment.getAttackEnrichment(input_dto),
@@ -26,6 +26,7 @@ def execute(self, input_dto: validate) -> DirectorOutputDto:
2626
[],
2727
[],
2828
[],
29+
[]
2930
)
3031

3132
director = Director(director_output_dto)

contentctl/input/director.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,14 @@
11
import os
22
import sys
3-
import pathlib
4-
from typing import Union
3+
from pathlib import Path
54
from dataclasses import dataclass, field
65
from pydantic import ValidationError
76
from uuid import UUID
87
from contentctl.input.yml_reader import YmlReader
98

10-
119
from contentctl.objects.detection import Detection
1210
from contentctl.objects.story import Story
1311

14-
from contentctl.objects.enums import SecurityContentProduct
1512
from contentctl.objects.baseline import Baseline
1613
from contentctl.objects.investigation import Investigation
1714
from contentctl.objects.playbook import Playbook
@@ -21,20 +18,15 @@
2118
from contentctl.objects.atomic import AtomicEnrichment
2219
from contentctl.objects.security_content_object import SecurityContentObject
2320
from contentctl.objects.data_source import DataSource
24-
from contentctl.objects.event_source import EventSource
25-
21+
from contentctl.objects.dashboard import Dashboard
2622
from contentctl.enrichments.attack_enrichment import AttackEnrichment
2723
from contentctl.enrichments.cve_enrichment import CveEnrichment
2824

2925
from contentctl.objects.config import validate
3026
from contentctl.objects.enums import SecurityContentType
31-
32-
from contentctl.objects.enums import DetectionStatus
3327
from contentctl.helper.utils import Utils
3428

3529

36-
37-
3830
@dataclass
3931
class DirectorOutputDto:
4032
# Atomic Tests are first because parsing them
@@ -50,6 +42,8 @@ class DirectorOutputDto:
5042
macros: list[Macro]
5143
lookups: list[Lookup]
5244
deployments: list[Deployment]
45+
dashboards: list[Dashboard]
46+
5347
data_sources: list[DataSource]
5448
name_to_content_map: dict[str, SecurityContentObject] = field(default_factory=dict)
5549
uuid_to_content_map: dict[UUID, SecurityContentObject] = field(default_factory=dict)
@@ -88,6 +82,9 @@ def addContentToDictMappings(self, content: SecurityContentObject):
8882
self.stories.append(content)
8983
elif isinstance(content, Detection):
9084
self.detections.append(content)
85+
elif isinstance(content, Dashboard):
86+
self.dashboards.append(content)
87+
9188
elif isinstance(content, DataSource):
9289
self.data_sources.append(content)
9390
else:
@@ -115,7 +112,7 @@ def execute(self, input_dto: validate) -> None:
115112
self.createSecurityContent(SecurityContentType.data_sources)
116113
self.createSecurityContent(SecurityContentType.playbooks)
117114
self.createSecurityContent(SecurityContentType.detections)
118-
115+
self.createSecurityContent(SecurityContentType.dashboards)
119116

120117
from contentctl.objects.abstract_security_content_objects.detection_abstract import MISSING_SOURCES
121118
if len(MISSING_SOURCES) > 0:
@@ -137,6 +134,7 @@ def createSecurityContent(self, contentType: SecurityContentType) -> None:
137134
SecurityContentType.playbooks,
138135
SecurityContentType.detections,
139136
SecurityContentType.data_sources,
137+
SecurityContentType.dashboards
140138
]:
141139
files = Utils.get_all_yml_files_from_directory(
142140
os.path.join(self.input_dto.path, str(contentType.name))
@@ -147,7 +145,7 @@ def createSecurityContent(self, contentType: SecurityContentType) -> None:
147145
else:
148146
raise (Exception(f"Cannot createSecurityContent for unknown product {contentType}."))
149147

150-
validation_errors = []
148+
validation_errors:list[tuple[Path,ValueError]] = []
151149

152150
already_ran = False
153151
progress_percent = 0
@@ -189,6 +187,10 @@ def createSecurityContent(self, contentType: SecurityContentType) -> None:
189187
elif contentType == SecurityContentType.detections:
190188
detection = Detection.model_validate(modelDict, context={"output_dto":self.output_dto, "app":self.input_dto.app})
191189
self.output_dto.addContentToDictMappings(detection)
190+
191+
elif contentType == SecurityContentType.dashboards:
192+
dashboard = Dashboard.model_validate(modelDict,context={"output_dto":self.output_dto})
193+
self.output_dto.addContentToDictMappings(dashboard)
192194

193195
elif contentType == SecurityContentType.data_sources:
194196
data_source = DataSource.model_validate(

contentctl/input/new_content_questions.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
from typing import Any
2+
3+
14
class NewContentQuestions:
25

36
@classmethod
4-
def get_questions_detection(self) -> list:
7+
def get_questions_detection(cls) -> list[dict[str,Any]]:
58
questions = [
69
{
710
"type": "text",
@@ -116,7 +119,7 @@ def get_questions_detection(self) -> list:
116119
return questions
117120

118121
@classmethod
119-
def get_questions_story(self) -> list:
122+
def get_questions_story(cls)-> list[dict[str,Any]]:
120123
questions = [
121124
{
122125
"type": "text",

contentctl/objects/abstract_security_content_objects/detection_abstract.py

Lines changed: 64 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@
2020
if TYPE_CHECKING:
2121
from contentctl.input.director import DirectorOutputDto
2222
from contentctl.objects.baseline import Baseline
23-
23+
from contentctl.objects.config import CustomApp
24+
2425
from contentctl.objects.security_content_object import SecurityContentObject
2526
from contentctl.objects.enums import AnalyticsType
2627
from contentctl.objects.enums import DataModel
@@ -36,10 +37,16 @@
3637
from contentctl.objects.data_source import DataSource
3738
from contentctl.objects.base_test_result import TestResultStatus
3839

39-
# from contentctl.objects.playbook import Playbook
4040
from contentctl.objects.enums import ProvidingTechnology
4141
from contentctl.enrichments.cve_enrichment import CveEnrichmentObj
4242
import datetime
43+
from contentctl.objects.constants import (
44+
ES_MAX_STANZA_LENGTH,
45+
ES_SEARCH_STANZA_NAME_FORMAT_AFTER_CLONING_IN_PRODUCT_TEMPLATE,
46+
CONTENTCTL_MAX_SEARCH_NAME_LENGTH,
47+
CONTENTCTL_DETECTION_STANZA_NAME_FORMAT_TEMPLATE
48+
)
49+
4350
MISSING_SOURCES: set[str] = set()
4451

4552
# Those AnalyticsTypes that we do not test via contentctl
@@ -51,15 +58,25 @@
5158
# TODO (#266): disable the use_enum_values configuration
5259
class Detection_Abstract(SecurityContentObject):
5360
model_config = ConfigDict(use_enum_values=True)
54-
55-
# contentType: SecurityContentType = SecurityContentType.detections
61+
name:str = Field(...,max_length=CONTENTCTL_MAX_SEARCH_NAME_LENGTH)
62+
#contentType: SecurityContentType = SecurityContentType.detections
5663
type: AnalyticsType = Field(...)
5764
status: DetectionStatus = Field(...)
5865
data_source: list[str] = []
5966
tags: DetectionTags = Field(...)
6067
search: str = Field(...)
6168
how_to_implement: str = Field(..., min_length=4)
6269
known_false_positives: str = Field(..., min_length=4)
70+
explanation: None | str = Field(
71+
default=None,
72+
exclude=True, #Don't serialize this value when dumping the object
73+
description="Provide an explanation to be included "
74+
"in the 'Explanation' field of the Detection in "
75+
"the Use Case Library. If this field is not "
76+
"defined in the YML, it will default to the "
77+
"value of the 'description' field when "
78+
"serialized in analyticstories_detections.j2",
79+
)
6380

6481
enabled_by_default: bool = False
6582
file_path: FilePath = Field(...)
@@ -70,10 +87,30 @@ class Detection_Abstract(SecurityContentObject):
7087
# https://github.com/pydantic/pydantic/issues/9101#issuecomment-2019032541
7188
tests: List[Annotated[Union[UnitTest, IntegrationTest, ManualTest], Field(union_mode='left_to_right')]] = []
7289
# A list of groups of tests, relying on the same data
73-
test_groups: Union[list[TestGroup], None] = Field(None, validate_default=True)
90+
test_groups: list[TestGroup] = []
7491

7592
data_source_objects: list[DataSource] = []
7693

94+
def get_conf_stanza_name(self, app:CustomApp)->str:
95+
stanza_name = CONTENTCTL_DETECTION_STANZA_NAME_FORMAT_TEMPLATE.format(app_label=app.label, detection_name=self.name)
96+
self.check_conf_stanza_max_length(stanza_name)
97+
return stanza_name
98+
99+
100+
def get_action_dot_correlationsearch_dot_label(self, app:CustomApp, max_stanza_length:int=ES_MAX_STANZA_LENGTH)->str:
101+
stanza_name = self.get_conf_stanza_name(app)
102+
stanza_name_after_saving_in_es = ES_SEARCH_STANZA_NAME_FORMAT_AFTER_CLONING_IN_PRODUCT_TEMPLATE.format(
103+
security_domain_value = self.tags.security_domain.value,
104+
search_name = stanza_name
105+
)
106+
107+
108+
if len(stanza_name_after_saving_in_es) > max_stanza_length:
109+
raise ValueError(f"label may only be {max_stanza_length} characters to allow updating in-product, "
110+
f"but stanza was actually {len(stanza_name_after_saving_in_es)} characters: '{stanza_name_after_saving_in_es}' ")
111+
112+
return stanza_name
113+
77114
@field_validator("search", mode="before")
78115
@classmethod
79116
def validate_presence_of_filter_macro(cls, value:str, info:ValidationInfo)->str:
@@ -519,7 +556,7 @@ def model_post_init(self, __context: Any) -> None:
519556
self.data_source_objects = matched_data_sources
520557

521558
for story in self.tags.analytic_story:
522-
story.detections.append(self)
559+
story.detections.append(self)
523560

524561
self.cve_enrichment_func(__context)
525562

@@ -654,6 +691,27 @@ def addTags_nist(self):
654691
else:
655692
self.tags.nist = [NistCategory.DE_AE]
656693
return self
694+
695+
696+
@model_validator(mode="after")
697+
def ensureThrottlingFieldsExist(self):
698+
'''
699+
For throttling to work properly, the fields to throttle on MUST
700+
exist in the search itself. If not, then we cannot apply the throttling
701+
'''
702+
if self.tags.throttling is None:
703+
# No throttling configured for this detection
704+
return self
705+
706+
missing_fields:list[str] = [field for field in self.tags.throttling.fields if field not in self.search]
707+
if len(missing_fields) > 0:
708+
raise ValueError(f"The following throttle fields were missing from the search: {missing_fields}")
709+
710+
else:
711+
# All throttling fields present in search
712+
return self
713+
714+
657715

658716
@model_validator(mode="after")
659717
def ensureProperObservablesExist(self):

contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
from contentctl.objects.deployment import Deployment
66
from contentctl.objects.security_content_object import SecurityContentObject
77
from contentctl.input.director import DirectorOutputDto
8+
from contentctl.objects.config import CustomApp
89

910
from contentctl.objects.enums import AnalyticsType
11+
from contentctl.objects.constants import CONTENTCTL_MAX_STANZA_LENGTH
1012
import abc
1113
import uuid
1214
import datetime
@@ -31,14 +33,14 @@
3133

3234
# TODO (#266): disable the use_enum_values configuration
3335
class SecurityContentObject_Abstract(BaseModel, abc.ABC):
34-
model_config = ConfigDict(use_enum_values=True, validate_default=True)
35-
36-
name: str = Field(...)
37-
author: str = Field("Content Author", max_length=255)
38-
date: datetime.date = Field(datetime.date.today())
39-
version: NonNegativeInt = 1
40-
id: uuid.UUID = Field(default_factory=uuid.uuid4) # we set a default here until all content has a uuid
41-
description: str = Field("Enter Description Here", max_length=10000)
36+
model_config = ConfigDict(use_enum_values=True,validate_default=True)
37+
38+
name: str = Field(...,max_length=99)
39+
author: str = Field(...,max_length=255)
40+
date: datetime.date = Field(...)
41+
version: NonNegativeInt = Field(...)
42+
id: uuid.UUID = Field(...) #we set a default here until all content has a uuid
43+
description: str = Field(...,max_length=10000)
4244
file_path: Optional[FilePath] = None
4345
references: Optional[List[HttpUrl]] = None
4446

@@ -56,7 +58,13 @@ def serialize_model(self):
5658
"description": self.description,
5759
"references": [str(url) for url in self.references or []]
5860
}
59-
61+
62+
63+
def check_conf_stanza_max_length(self, stanza_name:str, max_stanza_length:int=CONTENTCTL_MAX_STANZA_LENGTH) -> None:
64+
if len(stanza_name) > max_stanza_length:
65+
raise ValueError(f"conf stanza may only be {max_stanza_length} characters, "
66+
f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' ")
67+
6068
@staticmethod
6169
def objectListToNameList(objects: list[SecurityContentObject]) -> list[str]:
6270
return [object.getName() for object in objects]

0 commit comments

Comments
 (0)