From 104e71cfcad317e9cea52f973d5ca59e7989c25a Mon Sep 17 00:00:00 2001 From: linuxdaemon Date: Mon, 14 Aug 2023 12:04:14 -0400 Subject: [PATCH 01/46] Handle stopped containers in testing This means a failed container will no longer hang the test command --- .../DetectionTestingInfrastructureContainer.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py b/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py index c4ee664b..46c7b1dd 100644 --- a/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +++ b/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py @@ -41,13 +41,17 @@ def get_docker_client(self): def check_for_teardown(self): try: - self.get_docker_client().containers.get(self.get_name()) + container: docker.models.containers.Container = self.get_docker_client().containers.get(self.get_name()) except Exception as e: if self.sync_obj.terminate is not True: self.pbar.write( f"Error: could not get container [{self.get_name()}]: {str(e)}" ) self.sync_obj.terminate = True + else: + if container.status != 'running': + self.sync_obj.terminate = True + self.container = None if self.sync_obj.terminate: self.finish() From bb910f81678819caea79f503d11394530c5c2294 Mon Sep 17 00:00:00 2001 From: linuxdaemon Date: Mon, 14 Aug 2023 12:26:52 -0400 Subject: [PATCH 02/46] Update new content generator with new formats --- contentctl/input/new_content_generator.py | 5 +++-- contentctl/output/new_content_yml_output.py | 10 ++-------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/contentctl/input/new_content_generator.py b/contentctl/input/new_content_generator.py index 5e6b4ee1..b78d0864 100644 --- a/contentctl/input/new_content_generator.py +++ b/contentctl/input/new_content_generator.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from datetime import datetime -from contentctl.objects.enums import SecurityContentType +from contentctl.objects.enums import DetectionStatus, SecurityContentType from contentctl.input.new_content_questions import NewContentQuestions @@ -32,17 +32,18 @@ def execute(self, input_dto: NewContentGeneratorInputDto) -> None: self.output_dto.obj['name'] = answers['detection_name'] self.output_dto.obj['id'] = str(uuid.uuid4()) self.output_dto.obj['version'] = 1 + self.output_dto.obj['status'] = DetectionStatus.experimental.value self.output_dto.obj['date'] = datetime.today().strftime('%Y-%m-%d') self.output_dto.obj['author'] = answers['detection_author'] self.output_dto.obj['type'] = answers['detection_type'] self.output_dto.obj['datamodel'] = answers['datamodels'] - self.output_dto.obj['datamodel'] = answers['datamodels'] self.output_dto.obj['description'] = 'UPDATE_DESCRIPTION' file_name = self.output_dto.obj['name'].replace(' ', '_').replace('-','_').replace('.','_').replace('/','_').lower() self.output_dto.obj['search'] = answers['detection_search'] + ' | `' + file_name + '_filter`' self.output_dto.obj['how_to_implement'] = 'UPDATE_HOW_TO_IMPLEMENT' self.output_dto.obj['known_false_positives'] = 'UPDATE_KNOWN_FALSE_POSITIVES' self.output_dto.obj['references'] = ['REFERENCE'] + self.output_dto.obj['data_source'] = ['UPDATE'] self.output_dto.obj['tags'] = dict() self.output_dto.obj['tags']['analytic_story'] = ['UPDATE_STORY_NAME'] self.output_dto.obj['tags']['asset_type'] = 'UPDATE asset_type' diff --git a/contentctl/output/new_content_yml_output.py b/contentctl/output/new_content_yml_output.py index b70f1edf..8356e871 100644 --- a/contentctl/output/new_content_yml_output.py +++ b/contentctl/output/new_content_yml_output.py @@ -14,18 +14,14 @@ def __init__(self, output_path:str): def writeObjectNewContent(self, object: dict, type: SecurityContentType) -> None: if type == SecurityContentType.detections: file_path = os.path.join(self.output_path, 'detections', self.convertNameToFileName(object['name'], object['tags']['product'])) - test_obj = {} - test_obj['name'] = object['name'] + ' Unit Test' - test_obj['tests'] = [ + object['tests'] = [ { 'name': object['name'], - 'file': self.convertNameToFileName(object['name'],object['tags']['product']), 'pass_condition': '| stats count | where count > 0', 'earliest_time': '-24h', 'latest_time': 'now', 'attack_data': [ { - 'file_name': 'UPDATE', 'data': 'UPDATE', 'source': 'UPDATE', 'sourcetype': 'UPDATE', @@ -34,12 +30,10 @@ def writeObjectNewContent(self, object: dict, type: SecurityContentType) -> None ] } ] - file_path_test = os.path.join(self.output_path, 'tests', self.convertNameToTestFileName(object['name'], object['tags']['product'])) - YmlWriter.writeYmlFile(file_path_test, test_obj) #object.pop('source') YmlWriter.writeYmlFile(file_path, object) print("Successfully created detection " + file_path) - + elif type == SecurityContentType.stories: file_path = os.path.join(self.output_path, 'stories', self.convertNameToFileName(object['name'], object['tags']['product'])) YmlWriter.writeYmlFile(file_path, object) From 74874e252f5863f09c2d443bbdf845156bf9e2a7 Mon Sep 17 00:00:00 2001 From: linuxdaemon Date: Wed, 9 Aug 2023 16:39:26 -0400 Subject: [PATCH 03/46] Allow absent tests for experimental detections This is consistent with the validation check that allows no tests if status=experimental --- .../abstract_security_content_objects/detection_abstract.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index a554f703..f545c74b 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -139,7 +139,7 @@ def encode_error(cls, v, values, field): @validator("tests") def tests_validate(cls, v, values): - if values.get("status","") != DetectionStatus.production and not v: + if values.get("status","") == DetectionStatus.production and not v: raise ValueError( "tests value is needed for production detection: " + values["name"] ) @@ -153,7 +153,7 @@ def datamodel_valid(cls, v, values): return v def all_tests_successful(self) -> bool: - if len(self.tests) == 0: + if len(self.tests) == 0 and self.status is DetectionStatus.production: return False for test in self.tests: if test.result is None or test.result.success == False: From 59888f1ea7f44cecba142adc5cbde31f48d69a20 Mon Sep 17 00:00:00 2001 From: pyth0n1c <87383215+pyth0n1c@users.noreply.github.com> Date: Mon, 27 Nov 2023 17:27:03 -0800 Subject: [PATCH 04/46] Update detection_abstract.py Updated validator for "tests" so that it always runs, even if no tests are provided. without adding always=True, it fails to catch missing tests. Also, ensure that we run against the .value of the enumeration, not the enum object itself. This is required since The Pydantic config use_enum_values = True --- .../abstract_security_content_objects/detection_abstract.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index f545c74b..dd1dfd80 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -137,9 +137,9 @@ def encode_error(cls, v, values, field): # # Found everything # return v - @validator("tests") + @validator("tests", always=True) def tests_validate(cls, v, values): - if values.get("status","") == DetectionStatus.production and not v: + if values.get("status","") == DetectionStatus.production.value and not v: raise ValueError( "tests value is needed for production detection: " + values["name"] ) @@ -153,7 +153,7 @@ def datamodel_valid(cls, v, values): return v def all_tests_successful(self) -> bool: - if len(self.tests) == 0 and self.status is DetectionStatus.production: + if len(self.tests) == 0 and self.status is DetectionStatus.production.value: return False for test in self.tests: if test.result is None or test.result.success == False: From 350c99258475afccedc03c4f83efc51bd9921a7e Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Tue, 14 May 2024 11:24:53 -0700 Subject: [PATCH 05/46] Clean up messy object definitions in prep to update jinja2 templates --- contentctl/objects/baseline.py | 17 ++-------- contentctl/objects/baseline_tags.py | 41 +++--------------------- contentctl/objects/investigation.py | 16 ++++----- contentctl/objects/investigation_tags.py | 7 ++-- 4 files changed, 16 insertions(+), 65 deletions(-) diff --git a/contentctl/objects/baseline.py b/contentctl/objects/baseline.py index 91cb8958..f659e837 100644 --- a/contentctl/objects/baseline.py +++ b/contentctl/objects/baseline.py @@ -1,15 +1,11 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Annotated, Optional, List,Any +from typing import Annotated, Optional, List,Any from pydantic import field_validator, ValidationInfo, Field, model_serializer -if TYPE_CHECKING: - from contentctl.input.director import DirectorOutputDto - from contentctl.objects.deployment import Deployment from contentctl.objects.security_content_object import SecurityContentObject -from contentctl.objects.enums import DataModel, AnalyticsType +from contentctl.objects.enums import DataModel from contentctl.objects.baseline_tags import BaselineTags -from contentctl.objects.enums import DeploymentType #from contentctl.objects.deployment import Deployment # from typing import TYPE_CHECKING @@ -18,20 +14,11 @@ class Baseline(SecurityContentObject): - # baseline spec - #name: str - #id: str - #version: int - #date: str - #author: str - #contentType: SecurityContentType = SecurityContentType.baselines type: Annotated[str,Field(pattern="^Baseline$")] = Field(...) datamodel: Optional[List[DataModel]] = None - #description: str search: str = Field(..., min_length=4) how_to_implement: str = Field(..., min_length=4) known_false_positives: str = Field(..., min_length=4) - check_references: bool = False #Validation is done in order, this field must be defined first tags: BaselineTags = Field(...) # enrichment diff --git a/contentctl/objects/baseline_tags.py b/contentctl/objects/baseline_tags.py index fa0030dd..4817ce4b 100644 --- a/contentctl/objects/baseline_tags.py +++ b/contentctl/objects/baseline_tags.py @@ -1,24 +1,20 @@ from __future__ import annotations -from typing import TYPE_CHECKING from pydantic import BaseModel, Field, field_validator, ValidationInfo, model_serializer from typing import List, Any, Union from contentctl.objects.story import Story -from contentctl.objects.deployment import Deployment from contentctl.objects.detection import Detection from contentctl.objects.enums import SecurityContentProductName from contentctl.objects.enums import SecurityDomain -if TYPE_CHECKING: - from contentctl.input.director import DirectorOutputDto + class BaselineTags(BaseModel): - analytic_story: list[Story] = Field(...) - #deployment: Deployment = Field('SET_IN_GET_DEPLOYMENT_FUNCTION') + analytic_story: List[Story] = Field(...) detections: List[Union[Detection,str]] = Field(...) - product: list[SecurityContentProductName] = Field(...,min_length=1) + product: List[SecurityContentProductName] = Field(...,min_length=1) required_fields: List[str] = Field(...,min_length=1) security_domain: SecurityDomain = Field(...) @@ -42,33 +38,4 @@ def serialize_model(self): #return the model - return model - - def replaceDetectionNameWithDetectionObject(self, detection:Detection)->bool: - - pass - - - - - # @field_validator("deployment", mode="before") - # def getDeployment(cls, v:Any, info:ValidationInfo)->Deployment: - # if v != 'SET_IN_GET_DEPLOYMENT_FUNCTION': - # print(f"Deployment defined in YML: {v}") - # return v - - # director: Optional[DirectorOutputDto] = info.context.get("output_dto",None) - # if not director: - # raise ValueError("Cannot set deployment - DirectorOutputDto not passed to Detection Constructor in context") - - # typeField = "Baseline" - # deps = [deployment for deployment in director.deployments if deployment.type == typeField] - # if len(deps) == 1: - # return deps[0] - # elif len(deps) == 0: - # raise ValueError(f"Failed to find Deployment for type '{typeField}' "\ - # f"from possible {[deployment.type for deployment in director.deployments]}") - # else: - # raise ValueError(f"Found more than 1 ({len(deps)}) Deployment for type '{typeField}' "\ - # f"from possible {[deployment.type for deployment in director.deployments]}") - \ No newline at end of file + return model \ No newline at end of file diff --git a/contentctl/objects/investigation.py b/contentctl/objects/investigation.py index 1ca980ea..1b7aec84 100644 --- a/contentctl/objects/investigation.py +++ b/contentctl/objects/investigation.py @@ -1,9 +1,7 @@ from __future__ import annotations import re -from typing import TYPE_CHECKING, Optional, List, Any -from pydantic import field_validator, computed_field, Field, ValidationInfo, ConfigDict,model_serializer -if TYPE_CHECKING: - from contentctl.input.director import DirectorOutputDto +from typing import List, Any +from pydantic import computed_field, Field, ConfigDict,model_serializer from contentctl.objects.security_content_object import SecurityContentObject from contentctl.objects.enums import DataModel from contentctl.objects.investigation_tags import InvestigationTags @@ -26,11 +24,11 @@ class Investigation(SecurityContentObject): @property def inputs(self)->List[str]: #Parse out and return all inputs from the searchj - inputs = [] + inputs:List[str] = [] pattern = r"\$([^\s.]*)\$" for input in re.findall(pattern, self.search): - inputs.append(input) + inputs.append(str(input)) return inputs @@ -65,10 +63,8 @@ def serialize_model(self): def model_post_init(self, ctx:dict[str,Any]): - # director: Optional[DirectorOutputDto] = ctx.get("output_dto",None) - # if not isinstance(director,DirectorOutputDto): - # raise ValueError("DirectorOutputDto was not passed in context of Detection model_post_init") - director: Optional[DirectorOutputDto] = ctx.get("output_dto",None) + # Ensure we link all stories this investigation references + # back to itself for story in self.tags.analytic_story: story.investigations.append(self) diff --git a/contentctl/objects/investigation_tags.py b/contentctl/objects/investigation_tags.py index c01ac2cc..6db99eff 100644 --- a/contentctl/objects/investigation_tags.py +++ b/contentctl/objects/investigation_tags.py @@ -1,12 +1,13 @@ from __future__ import annotations +from typing import List from pydantic import BaseModel, Field, field_validator, ValidationInfo, model_serializer from contentctl.objects.story import Story from contentctl.objects.enums import SecurityContentInvestigationProductName, SecurityDomain class InvestigationTags(BaseModel): - analytic_story: list[Story] = Field([],min_length=1) - product: list[SecurityContentInvestigationProductName] = Field(...,min_length=1) - required_fields: list[str] = Field(min_length=1) + analytic_story: List[Story] = Field([],min_length=1) + product: List[SecurityContentInvestigationProductName] = Field(...,min_length=1) + required_fields: List[str] = Field(min_length=1) security_domain: SecurityDomain = Field(...) From e6db597c8f9654958a09a0a5ac8fdcb605bf9289 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Tue, 14 May 2024 12:08:03 -0700 Subject: [PATCH 06/46] Pass in entire app Object to jinja2 conf templates. Write the proper name of the app into the request.ui_dispatch_app values of savedsearches detections, baselines, and investigations. Other jinja2 changes are to cope with passing in the entire app object, not just the APP_NAME (which was actually the app.label field). --- contentctl/output/conf_writer.py | 6 +++--- .../templates/analyticstories_detections.j2 | 2 +- .../templates/analyticstories_investigations.j2 | 2 +- .../output/templates/analyticstories_stories.j2 | 2 +- .../output/templates/savedsearches_baselines.j2 | 7 ++++--- .../templates/savedsearches_detections.j2 | 17 +++++++++-------- .../templates/savedsearches_investigations.j2 | 9 +++++---- 7 files changed, 24 insertions(+), 21 deletions(-) diff --git a/contentctl/output/conf_writer.py b/contentctl/output/conf_writer.py index 5f66e032..735a9651 100644 --- a/contentctl/output/conf_writer.py +++ b/contentctl/output/conf_writer.py @@ -58,7 +58,7 @@ def writeManifestFile(app_output_path:pathlib.Path, template_name : str, config: j2_env = ConfWriter.getJ2Environment() template = j2_env.get_template(template_name) - output = template.render(objects=objects, APP_NAME=config.app.label, currentDate=datetime.datetime.now(datetime.UTC).date().isoformat()) + output = template.render(objects=objects, app=config.app, currentDate=datetime.datetime.now(datetime.UTC).date().isoformat()) output_path = config.getPackageDirectoryPath()/app_output_path output_path.parent.mkdir(parents=True, exist_ok=True) @@ -91,7 +91,7 @@ def writeXmlFile(app_output_path:pathlib.Path, template_name : str, config: buil j2_env = ConfWriter.getJ2Environment() template = j2_env.get_template(template_name) - output = template.render(objects=objects, APP_NAME=config.app.label) + output = template.render(objects=objects, app=config.app) output_path = config.getPackageDirectoryPath()/app_output_path output_path.parent.mkdir(parents=True, exist_ok=True) @@ -139,7 +139,7 @@ def writeConfFile(app_output_path:pathlib.Path, template_name : str, config: bui j2_env = ConfWriter.getJ2Environment() template = j2_env.get_template(template_name) - output = template.render(objects=objects, APP_NAME=config.app.label) + output = template.render(objects=objects, app=config.app) output_path.parent.mkdir(parents=True, exist_ok=True) with open(output_path, 'a') as f: diff --git a/contentctl/output/templates/analyticstories_detections.j2 b/contentctl/output/templates/analyticstories_detections.j2 index 290c4c85..b119632a 100644 --- a/contentctl/output/templates/analyticstories_detections.j2 +++ b/contentctl/output/templates/analyticstories_detections.j2 @@ -3,7 +3,7 @@ {% for detection in objects %} {% if (detection.type == 'TTP' or detection.type == 'Anomaly' or detection.type == 'Hunting' or detection.type == 'Correlation') %} -[savedsearch://{{APP_NAME}} - {{ detection.name }} - Rule] +[savedsearch://{{app.label}} - {{ detection.name }} - Rule] type = detection asset_type = {{ detection.tags.asset_type.value }} confidence = medium diff --git a/contentctl/output/templates/analyticstories_investigations.j2 b/contentctl/output/templates/analyticstories_investigations.j2 index e742c909..a7cfa37c 100644 --- a/contentctl/output/templates/analyticstories_investigations.j2 +++ b/contentctl/output/templates/analyticstories_investigations.j2 @@ -3,7 +3,7 @@ {% for detection in objects %} {% if (detection.type == 'Investigation') %} -[savedsearch://{{APP_NAME}} - {{ detection.name }} - Response Task] +[savedsearch://{{app.label}} - {{ detection.name }} - Response Task] type = investigation explanation = none {% if detection.how_to_implement is defined %} diff --git a/contentctl/output/templates/analyticstories_stories.j2 b/contentctl/output/templates/analyticstories_stories.j2 index 9723a6dd..165d676c 100644 --- a/contentctl/output/templates/analyticstories_stories.j2 +++ b/contentctl/output/templates/analyticstories_stories.j2 @@ -10,7 +10,7 @@ version = {{ story.version }} references = {{ story.getReferencesListForJson() | tojson }} maintainers = [{"company": "{{ story.author_company }}", "email": "{{ story.author_email }}", "name": "{{ story.author_name }}"}] spec_version = 3 -searches = {{ story.storyAndInvestigationNamesWithApp(APP_NAME) | tojson }} +searches = {{ story.storyAndInvestigationNamesWithApp(app.label) | tojson }} description = {{ story.description | escapeNewlines() }} {% if story.narrative is defined %} narrative = {{ story.narrative | escapeNewlines() }} diff --git a/contentctl/output/templates/savedsearches_baselines.j2 b/contentctl/output/templates/savedsearches_baselines.j2 index caf00fc0..1d0c88f5 100644 --- a/contentctl/output/templates/savedsearches_baselines.j2 +++ b/contentctl/output/templates/savedsearches_baselines.j2 @@ -1,14 +1,14 @@ -### {{APP_NAME}} BASELINES ### +### {{app.label}} BASELINES ### {% for detection in objects %} {% if (detection.type == 'Baseline') %} -[{{APP_NAME}} - {{ detection.name }}] +[{{app.label}} - {{ detection.name }}] action.escu = 0 action.escu.enabled = 1 action.escu.search_type = support -action.escu.full_search_name = {{APP_NAME}} - {{ detection.name }} +action.escu.full_search_name = {{app.label}} - {{ detection.name }} description = {{ detection.description | escapeNewlines() }} action.escu.creation_date = {{ detection.date }} action.escu.modification_date = {{ detection.date }} @@ -43,6 +43,7 @@ disabled = true {% endif %} is_visible = false search = {{ detection.search | escapeNewlines() }} +request.ui_dispatch_app = {{ app.appid }} {% endif %} {% endfor %} diff --git a/contentctl/output/templates/savedsearches_detections.j2 b/contentctl/output/templates/savedsearches_detections.j2 index 92db3833..e4ffe568 100644 --- a/contentctl/output/templates/savedsearches_detections.j2 +++ b/contentctl/output/templates/savedsearches_detections.j2 @@ -1,8 +1,8 @@ -### {{APP_NAME}} DETECTIONS ### +### {{app.label}} DETECTIONS ### {% for detection in objects %} {% if (detection.type == 'TTP' or detection.type == 'Anomaly' or detection.type == 'Hunting' or detection.type == 'Correlation') %} -[{{APP_NAME}} - {{ detection.name }} - Rule] +[{{app.label}} - {{ detection.name }} - Rule] action.escu = 0 action.escu.enabled = 1 {% if detection.status == "deprecated" %} @@ -28,7 +28,7 @@ action.escu.known_false_positives = None action.escu.creation_date = {{ detection.date }} action.escu.modification_date = {{ detection.date }} action.escu.confidence = high -action.escu.full_search_name = {{APP_NAME}} - {{ detection.name }} - Rule +action.escu.full_search_name = {{app.label}} - {{ detection.name }} - Rule action.escu.search_type = detection {% if detection.tags.product is defined %} action.escu.product = {{ detection.tags.product | tojson }} @@ -58,13 +58,13 @@ dispatch.earliest_time = {{ detection.deployment.scheduling.earliest_time }} dispatch.latest_time = {{ detection.deployment.scheduling.latest_time }} action.correlationsearch.enabled = 1 {% if detection.status == "deprecated" %} -action.correlationsearch.label = {{APP_NAME}} - Deprecated - {{ detection.name }} - Rule +action.correlationsearch.label = {{app.label}} - Deprecated - {{ detection.name }} - Rule {% elif detection.status == "experimental" %} -action.correlationsearch.label = {{APP_NAME}} - Experimental - {{ detection.name }} - Rule +action.correlationsearch.label = {{app.label}} - Experimental - {{ detection.name }} - Rule {% elif detection.type | lower == "correlation" %} -action.correlationsearch.label = {{APP_NAME}} - RIR - {{ detection.name }} - Rule +action.correlationsearch.label = {{app.label}} - RIR - {{ detection.name }} - Rule {% else %} -action.correlationsearch.label = {{APP_NAME}} - {{ detection.name }} - Rule +action.correlationsearch.label = {{app.label}} - {{ detection.name }} - Rule {% endif %} action.correlationsearch.annotations = {{ detection.annotations | tojson }} action.correlationsearch.metadata = {{ detection.getMetadata() | tojson }} @@ -116,7 +116,8 @@ quantity = 0 realtime_schedule = 0 is_visible = false search = {{ detection.search | escapeNewlines() }} +request.ui_dispatch_app = {{ app.appid }} {% endif %} {% endfor %} -### END {{ APP_NAME }} DETECTIONS ### +### END {{ app.label }} DETECTIONS ### diff --git a/contentctl/output/templates/savedsearches_investigations.j2 b/contentctl/output/templates/savedsearches_investigations.j2 index d80a2420..18d4952c 100644 --- a/contentctl/output/templates/savedsearches_investigations.j2 +++ b/contentctl/output/templates/savedsearches_investigations.j2 @@ -1,15 +1,15 @@ -### {{APP_NAME}} RESPONSE TASKS ### +### {{app.label}} RESPONSE TASKS ### {% for detection in objects %} {% if (detection.type == 'Investigation') %} {% if detection.search is defined %} -[{{APP_NAME}} - {{ detection.name }} - Response Task] +[{{app.label}} - {{ detection.name }} - Response Task] action.escu = 0 action.escu.enabled = 1 action.escu.search_type = investigative -action.escu.full_search_name = {{APP_NAME}} - {{ detection.name }} - Response Task +action.escu.full_search_name = {{app.label}} - {{ detection.name }} - Response Task description = {{ detection.description | escapeNewlines() }} action.escu.creation_date = {{ detection.date }} action.escu.modification_date = {{ detection.date }} @@ -29,10 +29,11 @@ disabled = true schedule_window = auto is_visible = false search = {{ detection.search | escapeNewlines() }} +request.ui_dispatch_app = {{ app.appid }} {% endif %} {% endif %} {% endfor %} -### END {{ APP_NAME }} RESPONSE TASKS ### \ No newline at end of file +### END {{ app.label }} RESPONSE TASKS ### \ No newline at end of file From 133af6f793eb2d1eb5cf3583aa4bd89fccdaf444 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Thu, 16 May 2024 17:48:20 -0700 Subject: [PATCH 07/46] Add experimental support for dashboards as first-class SecurityContentObjects. --- contentctl/actions/build.py | 1 + contentctl/actions/validate.py | 2 +- contentctl/input/director.py | 12 ++++++-- contentctl/objects/dashboard.py | 53 ++++++++++++++++++++++++++++++++ contentctl/objects/enums.py | 1 + contentctl/output/conf_output.py | 4 +++ contentctl/output/conf_writer.py | 13 ++++++++ 7 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 contentctl/objects/dashboard.py diff --git a/contentctl/actions/build.py b/contentctl/actions/build.py index a0d46195..af589218 100644 --- a/contentctl/actions/build.py +++ b/contentctl/actions/build.py @@ -38,6 +38,7 @@ def execute(self, input_dto: BuildInputDto) -> DirectorOutputDto: updated_conf_files.update(conf_output.writeObjects(input_dto.director_output_dto.investigations, SecurityContentType.investigations)) updated_conf_files.update(conf_output.writeObjects(input_dto.director_output_dto.lookups, SecurityContentType.lookups)) updated_conf_files.update(conf_output.writeObjects(input_dto.director_output_dto.macros, SecurityContentType.macros)) + updated_conf_files.update(conf_output.writeObjects(input_dto.director_output_dto.dashboards, SecurityContentType.dashboards)) updated_conf_files.update(conf_output.writeAppConf()) #Ensure that the conf file we just generated/update is syntactically valid diff --git a/contentctl/actions/validate.py b/contentctl/actions/validate.py index 90394b96..8d7f5eed 100644 --- a/contentctl/actions/validate.py +++ b/contentctl/actions/validate.py @@ -23,7 +23,7 @@ def execute(self, input_dto: validate) -> DirectorOutputDto: director_output_dto = DirectorOutputDto(AtomicTest.getAtomicTestsFromArtRepo(repo_path=input_dto.getAtomicRedTeamRepoPath(), enabled=input_dto.enrichments), AttackEnrichment.getAttackEnrichment(input_dto), - [],[],[],[],[],[],[],[],[]) + [],[],[],[],[],[],[],[],[],[]) director = Director(director_output_dto) diff --git a/contentctl/input/director.py b/contentctl/input/director.py index eef9879a..a5c2fa7b 100644 --- a/contentctl/input/director.py +++ b/contentctl/input/director.py @@ -20,6 +20,7 @@ from contentctl.objects.lookup import Lookup from contentctl.objects.ssa_detection import SSADetection from contentctl.objects.atomic import AtomicTest +from contentctl.objects.dashboard import Dashboard from contentctl.objects.security_content_object import SecurityContentObject from contentctl.enrichments.attack_enrichment import AttackEnrichment @@ -43,6 +44,7 @@ class DirectorOutputDto: macros: list[Macro] lookups: list[Lookup] deployments: list[Deployment] + dashboards: list[Dashboard] ssa_detections: list[SSADetection] #cve_enrichment: CveEnrichment @@ -110,7 +112,7 @@ def execute(self, input_dto: validate) -> None: self.createSecurityContent(SecurityContentType.investigations) self.createSecurityContent(SecurityContentType.playbooks) self.createSecurityContent(SecurityContentType.detections) - + self.createSecurityContent(SecurityContentType.dashboards) self.createSecurityContent(SecurityContentType.ssa_detections) @@ -127,7 +129,8 @@ def createSecurityContent(self, contentType: SecurityContentType) -> None: SecurityContentType.baselines, SecurityContentType.investigations, SecurityContentType.playbooks, - SecurityContentType.detections]: + SecurityContentType.detections, + SecurityContentType.dashboards,]: files = Utils.get_all_yml_files_from_directory(os.path.join(self.input_dto.path, str(contentType.name))) security_content_files = [f for f in files if not f.name.startswith('ssa___')] else: @@ -183,6 +186,11 @@ def createSecurityContent(self, contentType: SecurityContentType) -> None: detection = Detection.model_validate(modelDict,context={"output_dto":self.output_dto}) self.output_dto.detections.append(detection) self.addContentToDictMappings(detection) + + elif contentType == SecurityContentType.dashboards: + dashboard = Dashboard.model_validate(modelDict,context={"output_dto":self.output_dto}) + self.output_dto.dashboards.append(dashboard) + self.addContentToDictMappings(dashboard) elif contentType == SecurityContentType.ssa_detections: self.constructSSADetection(self.ssa_detection_builder, self.output_dto,str(file)) diff --git a/contentctl/objects/dashboard.py b/contentctl/objects/dashboard.py new file mode 100644 index 00000000..0db5260b --- /dev/null +++ b/contentctl/objects/dashboard.py @@ -0,0 +1,53 @@ +from typing import Any +from pydantic import Field, Json +import pathlib +import copy +from jinja2 import Environment +import json +from contentctl.objects.security_content_object import SecurityContentObject +from contentctl.objects.config import build + +DEFAULT_DASHBAORD_JINJA2_TEMPLATE = ''' + + + + +''' + +class Dashboard(SecurityContentObject): + j2_template: str = Field(default=DEFAULT_DASHBAORD_JINJA2_TEMPLATE, description="Jinja2 Template used to construct the dashboard") + json_obj: Json[dict[str,Any]] = Field(..., description="Valid JSON object that describes the dashboard") + description: str = Field(...,max_length=10000) + + + def getOutputFilepathRelativeToAppRoot(self, config:build)->pathlib.Path: + filename = f"{config.app.label}_{self.name}.xml".lower() + return pathlib.Path("default/data/ui/views")/filename + + def getLabelTag(self, config:build)->str: + return f"{config.app.label} - {self.name}" + + def getJsonWithDescriptionAsString(self)->str: + copied_json:Json[dict[str,Any]] = copy.deepcopy(self.json_obj) + copied_json['description']=self.description + return json.dumps(copied_json, indent=4) + + def writeDashboardFile(self, j2_env:Environment, config:build): + template = j2_env.from_string(self.j2_template) + dashboard_text = template.render(config=config, dashboard=self) + + with open(config.getPackageDirectoryPath()/self.getOutputFilepathRelativeToAppRoot(config), 'a') as f: + output_xml = dashboard_text.encode('utf-8', 'ignore').decode('utf-8') + f.write(output_xml) + + + + diff --git a/contentctl/objects/enums.py b/contentctl/objects/enums.py index 5cb06400..ad9b1ff9 100644 --- a/contentctl/objects/enums.py +++ b/contentctl/objects/enums.py @@ -55,6 +55,7 @@ class SecurityContentType(enum.Enum): investigations = 8 unit_tests = 9 ssa_detections = 10 + dashboards = 11 # Bringing these changes back in line will take some time after # the initial merge is complete diff --git a/contentctl/output/conf_output.py b/contentctl/output/conf_output.py index db5d6e28..aa7228e2 100644 --- a/contentctl/output/conf_output.py +++ b/contentctl/output/conf_output.py @@ -152,6 +152,10 @@ def writeObjects(self, objects: list, type: SecurityContentType = None) -> set[p 'macros.j2', self.config, objects)) + elif type == SecurityContentType.dashboards: + written_files.update(ConfWriter.writeDashboardFiles(self.config, objects)) + + return written_files diff --git a/contentctl/output/conf_writer.py b/contentctl/output/conf_writer.py index 2c8e82f7..ec48e47b 100644 --- a/contentctl/output/conf_writer.py +++ b/contentctl/output/conf_writer.py @@ -8,6 +8,7 @@ from jinja2 import Environment, FileSystemLoader, StrictUndefined import pathlib from contentctl.objects.security_content_object import SecurityContentObject +from contentctl.objects.dashboard import Dashboard from contentctl.objects.config import build import xml.etree.ElementTree as ET @@ -104,6 +105,17 @@ def writeXmlFile(app_output_path:pathlib.Path, template_name : str, config: buil + @staticmethod + def writeDashboardFiles(config:build, dashboards:list[Dashboard])->set[pathlib.Path]: + written_files:set[pathlib.Path] = set() + for dashboard in dashboards: + output_file_path = dashboard.getOutputFilepathRelativeToAppRoot(config) + ConfWriter.writeXmlFileHeader(output_file_path, config) + dashboard.writeDashboardFile(ConfWriter.getJ2Environment(), config) + ConfWriter.validateXmlFile(config.getPackageDirectoryPath()/output_file_path) + written_files.add(output_file_path) + return written_files + @staticmethod def writeXmlFileHeader(app_output_path:pathlib.Path, config: build) -> None: @@ -112,6 +124,7 @@ def writeXmlFileHeader(app_output_path:pathlib.Path, config: build) -> None: output_path = config.getPackageDirectoryPath()/app_output_path output_path.parent.mkdir(parents=True, exist_ok=True) + print(f"wrote {output_path}") with open(output_path, 'w') as f: output_with_xml_comment = output_with_xml_comment.encode('utf-8', 'ignore').decode('utf-8') f.write(output_with_xml_comment) From 3aa669bb576024c63dee862026f51ecae25ad7b3 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Fri, 17 May 2024 15:17:25 -0700 Subject: [PATCH 08/46] Change to always set ui_dispatch_app as SplunkEnterpriseSecuritySuite --- contentctl/objects/config.py | 6 ++++++ contentctl/output/templates/savedsearches_baselines.j2 | 2 +- contentctl/output/templates/savedsearches_detections.j2 | 2 +- contentctl/output/templates/savedsearches_investigations.j2 | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/contentctl/objects/config.py b/contentctl/objects/config.py index f036d132..7dbaab52 100644 --- a/contentctl/objects/config.py +++ b/contentctl/objects/config.py @@ -147,6 +147,12 @@ def getApp(self, config:test, stage_file=True)->str: str(destination), verbose_print=True) return str(destination) + + def get_ui_dispatch_app(self)->str: + ''' + Populate the request.ui_dispatch_app field in savedsearches.conf + ''' + return "SplunkEnterpriseSecuritySuite" class Config_Base(BaseModel): diff --git a/contentctl/output/templates/savedsearches_baselines.j2 b/contentctl/output/templates/savedsearches_baselines.j2 index 1d0c88f5..9557115e 100644 --- a/contentctl/output/templates/savedsearches_baselines.j2 +++ b/contentctl/output/templates/savedsearches_baselines.j2 @@ -43,7 +43,7 @@ disabled = true {% endif %} is_visible = false search = {{ detection.search | escapeNewlines() }} -request.ui_dispatch_app = {{ app.appid }} +request.ui_dispatch_app = {{ app.get_ui_dispatch_app() }} {% endif %} {% endfor %} diff --git a/contentctl/output/templates/savedsearches_detections.j2 b/contentctl/output/templates/savedsearches_detections.j2 index e4ffe568..39d5c630 100644 --- a/contentctl/output/templates/savedsearches_detections.j2 +++ b/contentctl/output/templates/savedsearches_detections.j2 @@ -116,7 +116,7 @@ quantity = 0 realtime_schedule = 0 is_visible = false search = {{ detection.search | escapeNewlines() }} -request.ui_dispatch_app = {{ app.appid }} +request.ui_dispatch_app = {{ app.get_ui_dispatch_app() }} {% endif %} {% endfor %} diff --git a/contentctl/output/templates/savedsearches_investigations.j2 b/contentctl/output/templates/savedsearches_investigations.j2 index 18d4952c..98b7304c 100644 --- a/contentctl/output/templates/savedsearches_investigations.j2 +++ b/contentctl/output/templates/savedsearches_investigations.j2 @@ -29,7 +29,7 @@ disabled = true schedule_window = auto is_visible = false search = {{ detection.search | escapeNewlines() }} -request.ui_dispatch_app = {{ app.appid }} +request.ui_dispatch_app = {{ app.get_ui_dispatch_app() }} {% endif %} {% endif %} From c6aa99d0b5160d909417f65dfb55792dd6085355 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Thu, 20 Jun 2024 11:30:34 -0700 Subject: [PATCH 09/46] Add fields as requested --- contentctl/output/templates/finding_report.j2 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contentctl/output/templates/finding_report.j2 b/contentctl/output/templates/finding_report.j2 index e965946a..33f10db0 100644 --- a/contentctl/output/templates/finding_report.j2 +++ b/contentctl/output/templates/finding_report.j2 @@ -1,9 +1,9 @@ - | eval devices = [{"hostname": device_hostname, "type_id": 0, "uuid": device.uuid}], + | eval devices = [{"hostname": device_hostname, "type_id": 0, "uuid": device.uuid, "ip": device.ip, "mac": device.mac}], time = timestamp, evidence = {{ detection.tags.evidence_str }}, message = "{{ detection.name }} has been triggered on " + device_hostname + " by " + {{ actor_user_name }} + ".", - users = [{"name": {{ actor_user_name }}, "uuid": actor_user.uuid, "uid": actor_user.uid}], + users = [{"name": {{ actor_user_name }}, "uuid": actor_user.uuid, "uid": actor_user.uid, "account_uid": actor_user.account_uid, "email_addr": actor_user.email_addr}], activity_id = 1, cis_csc = [{"control": "CIS 10", "version": 8}], analytic_stories = {{ detection.tags.analytics_story_str }}, From b56b8083d936ea59fbcaa8d16c7979ed6ace16be Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Tue, 2 Jul 2024 12:27:49 -0700 Subject: [PATCH 10/46] bump version in contentctl.yml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 4f67beb5..28bfa0bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "contentctl" -version = "4.1.0" +version = "4.2.0" description = "Splunk Content Control Tool" authors = ["STRT "] license = "Apache 2.0" From 10d33551bebd03042e061b45ba2a89fb9a8e73de Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Tue, 2 Jul 2024 15:38:26 -0700 Subject: [PATCH 11/46] Some improvements to make sure that the description and name fields, if present in the YML, are identical to those which MUST be included in ther JSON object. --- contentctl/input/director.py | 3 +- contentctl/objects/dashboard.py | 60 +++++++++++++++++++++++++------- contentctl/output/conf_writer.py | 1 - 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/contentctl/input/director.py b/contentctl/input/director.py index d09974c3..0c4bbab5 100644 --- a/contentctl/input/director.py +++ b/contentctl/input/director.py @@ -87,6 +87,8 @@ def addContentToDictMappings(self, content: SecurityContentObject): self.detections.append(content) elif isinstance(content, SSADetection): self.ssa_detections.append(content) + elif isinstance(content, Dashboard): + self.dashboards.append(content) else: raise Exception(f"Unknown security content type: {type(content)}") @@ -195,7 +197,6 @@ def createSecurityContent(self, contentType: SecurityContentType) -> None: elif contentType == SecurityContentType.dashboards: dashboard = Dashboard.model_validate(modelDict,context={"output_dto":self.output_dto}) - self.output_dto.dashboards.append(dashboard) self.output_dto.addContentToDictMappings(dashboard) diff --git a/contentctl/objects/dashboard.py b/contentctl/objects/dashboard.py index 0db5260b..20196add 100644 --- a/contentctl/objects/dashboard.py +++ b/contentctl/objects/dashboard.py @@ -1,5 +1,6 @@ from typing import Any -from pydantic import Field, Json +from pydantic import Field, Json, model_validator + import pathlib import copy from jinja2 import Environment @@ -8,10 +9,10 @@ from contentctl.objects.config import build DEFAULT_DASHBAORD_JINJA2_TEMPLATE = ''' - + Any: + raw_json = data.get("json_obj", None) + + try: + json_obj = json.loads(raw_json) + except Exception as e: + raise ValueError(f"Error getting field 'json_obj'. Field does not contain valid JSON: {str(e)}") + + name_from_file = data.get("name",None) + name_from_json = json_obj.get("title",None) + description_from_file = data.get("description",None) + description_from_json = json_obj.get("description",None) + + errors:list[str] = [] + if name_from_json is None: + errors.append("'title' field is missing from field 'json_object'") + elif name_from_json is not None and name_from_file is None: + name_from_file = name_from_json + elif name_from_json != name_from_file: + errors.append(f"title 'json_object' is '{name_from_json}', but the name defined in the YML is '{name_from_file}'. These two must match.") + if description_from_json is None: + errors.append("'description' field is missing from field 'json_object'") + elif description_from_json is not None and description_from_file is None: + description_from_file = description_from_file + elif description_from_json != description_from_file: + errors.append(f"description in 'json_object' is '{description_from_json}', but the description defined in the YML is '{description_from_file}'. These two must match.") + + data['name'] = name_from_json + data['description'] = description_from_json + if len(errors) > 0 : + err_string = "\n".join(errors) + raise ValueError(f"Error(s) validating dashboard:\n{err_string}") + return data + + def pretty_print_json_obj(self): + return json.dumps(self.json_obj, indent=4) + def getOutputFilepathRelativeToAppRoot(self, config:build)->pathlib.Path: - filename = f"{config.app.label}_{self.name}.xml".lower() + filename = f"{self.file_path.stem}.xml".lower() return pathlib.Path("default/data/ui/views")/filename - def getLabelTag(self, config:build)->str: - return f"{config.app.label} - {self.name}" - - def getJsonWithDescriptionAsString(self)->str: - copied_json:Json[dict[str,Any]] = copy.deepcopy(self.json_obj) - copied_json['description']=self.description - return json.dumps(copied_json, indent=4) def writeDashboardFile(self, j2_env:Environment, config:build): template = j2_env.from_string(self.j2_template) @@ -49,5 +85,3 @@ def writeDashboardFile(self, j2_env:Environment, config:build): f.write(output_xml) - - diff --git a/contentctl/output/conf_writer.py b/contentctl/output/conf_writer.py index ec48e47b..ea1f420e 100644 --- a/contentctl/output/conf_writer.py +++ b/contentctl/output/conf_writer.py @@ -124,7 +124,6 @@ def writeXmlFileHeader(app_output_path:pathlib.Path, config: build) -> None: output_path = config.getPackageDirectoryPath()/app_output_path output_path.parent.mkdir(parents=True, exist_ok=True) - print(f"wrote {output_path}") with open(output_path, 'w') as f: output_with_xml_comment = output_with_xml_comment.encode('utf-8', 'ignore').decode('utf-8') f.write(output_with_xml_comment) From 52973c2f1d0c393c928ee8b565466e46510e85db Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Wed, 3 Jul 2024 16:22:54 -0700 Subject: [PATCH 12/46] remove newline --- .../abstract_security_content_objects/detection_abstract.py | 1 - 1 file changed, 1 deletion(-) diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index c4c4d57c..e4ecf82f 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -598,7 +598,6 @@ def search_observables_exist_validate(self): return self - @model_validator(mode='after') def ensurePresenceOfRequiredTests(self): # TODO (cmcginley): Fix detection_abstract.tests_validate so that it surfaces validation errors From 10211115afc76501c9a0cdd83ee3f7b0213d9711 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Mon, 15 Jul 2024 15:01:13 -0700 Subject: [PATCH 13/46] Fix error on missing roles if es is not installed and we are not doing an es integration test --- .../DetectionTestingInfrastructure.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py b/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py index 1e892905..a2159a4b 100644 --- a/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +++ b/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py @@ -272,17 +272,25 @@ def configure_imported_roles( ): indexes.append(self.sync_obj.replay_index) indexes_encoded = ";".join(indexes) + try: + # Set which roles should be configured. For Enterprise Security/Integration Testing, + # we must add some extra foles. + if self.global_config.enable_integration_testing: + roles = imported_roles + enterprise_security_roles + else: + roles = imported_roles + self.get_conn().roles.post( self.infrastructure.splunk_app_username, - imported_roles=imported_roles + enterprise_security_roles, + imported_roles=roles, srchIndexesAllowed=indexes_encoded, srchIndexesDefault=self.sync_obj.replay_index, ) return except Exception as e: self.pbar.write( - f"Enterprise Security Roles do not exist:'{enterprise_security_roles}: {str(e)}" + f"The follwoing roles do not exist:'{enterprise_security_roles}: {str(e)}" ) self.get_conn().roles.post( From eaf87f264d5b468aaf3950ae9aa80f725bbe032f Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Mon, 15 Jul 2024 16:23:05 -0700 Subject: [PATCH 14/46] improve output of risk severity field. it is now calculated using the risk score --- contentctl/objects/detection_tags.py | 36 +++++++++++-------- contentctl/objects/enums.py | 14 ++++---- .../templates/savedsearches_detections.j2 | 2 +- 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/contentctl/objects/detection_tags.py b/contentctl/objects/detection_tags.py index 87667c29..855f6127 100644 --- a/contentctl/objects/detection_tags.py +++ b/contentctl/objects/detection_tags.py @@ -11,7 +11,7 @@ from contentctl.objects.mitre_attack_enrichment import MitreAttackEnrichment from contentctl.objects.constants import * from contentctl.objects.observable import Observable -from contentctl.objects.enums import Cis18Value, AssetType, SecurityDomain, RiskSeverity, KillChainPhase, NistCategory, RiskLevel, SecurityContentProductName +from contentctl.objects.enums import Cis18Value, AssetType, SecurityDomain, RiskSeverity, KillChainPhase, NistCategory, SecurityContentProductName from contentctl.objects.atomic import AtomicTest @@ -30,6 +30,23 @@ class DetectionTags(BaseModel): def risk_score(self)->int: return round((self.confidence * self.impact)/100) + @computed_field + @property + def severity(self)->RiskSeverity: + if 0 <= self.risk_score <= 20: + return RiskSeverity.INFO + elif 20 < self.risk_score <= 40: + return RiskSeverity.LOW + elif 40 < self.risk_score <= 60: + return RiskSeverity.MEDIUM + elif 60 < self.risk_score <= 80: + return RiskSeverity.HIGH + elif 80 < self.risk_score <= 100: + return RiskSeverity.CRITICAL + else: + raise Exception(f"Error getting severity - risk_score must be between 0-100, but was actually {self.risk_score}") + + mitre_attack_id: List[Annotated[str, Field(pattern="^T[0-9]{4}(.[0-9]{3})?$")]] = [] nist: list[NistCategory] = [] @@ -40,17 +57,6 @@ def risk_score(self)->int: security_domain: SecurityDomain = Field(...) - @computed_field - @property - def risk_severity(self)->RiskSeverity: - if self.risk_score >= 80: - return RiskSeverity('high') - elif (self.risk_score >= 50 and self.risk_score <= 79): - return RiskSeverity('medium') - else: - return RiskSeverity('low') - - cve: List[Annotated[str, "^CVE-[1|2][0-9]{3}-[0-9]+$"]] = [] atomic_guid: List[AtomicTest] = [] @@ -62,8 +68,8 @@ def risk_severity(self)->RiskSeverity: confidence_id: Optional[PositiveInt] = Field(None,ge=1,le=3) impact_id: Optional[PositiveInt] = Field(None,ge=1,le=5) # context_ids: list = None - risk_level_id: Optional[NonNegativeInt] = Field(None,le=4) - risk_level: Optional[RiskLevel] = None + + #observable_str: str = None evidence_str: Optional[str] = None @@ -137,7 +143,7 @@ def serialize_model(self): "message": self.message, "risk_score": self.risk_score, "security_domain": self.security_domain, - "risk_severity": self.risk_severity, + "risk_severity": self.severity, "mitre_attack_id": self.mitre_attack_id, "mitre_attack_enrichments": self.mitre_attack_enrichments } diff --git a/contentctl/objects/enums.py b/contentctl/objects/enums.py index e7016033..cd8c1bbe 100644 --- a/contentctl/objects/enums.py +++ b/contentctl/objects/enums.py @@ -408,14 +408,16 @@ class NistCategory(str, enum.Enum): RC_IM = "RC.IM" RC_CO = "RC.CO" -class RiskLevel(str,enum.Enum): +class RiskSeverity(str,enum.Enum): + # Levels taken from the following documentation link + # https://docs.splunk.com/Documentation/ES/7.3.2/User/RiskScoring + # 20 - Info (0-20 for us) + # 40 - Low (21-40 for us) + # 60 - Medium (41-60 for us) + # 80 - High (61-80 for us) + # 100 - Critical (81 - 100 for us) INFO = "Info" LOW = "Low" MEDIUM = "Medium" HIGH = "High" CRITICAL = "Critical" - -class RiskSeverity(str,enum.Enum): - LOW = "low" - MEDIUM = "medium" - HIGH = "high" diff --git a/contentctl/output/templates/savedsearches_detections.j2 b/contentctl/output/templates/savedsearches_detections.j2 index 92db3833..37edf13f 100644 --- a/contentctl/output/templates/savedsearches_detections.j2 +++ b/contentctl/output/templates/savedsearches_detections.j2 @@ -80,7 +80,7 @@ action.notable.param.nes_fields = {{ detection.nes_fields }} action.notable.param.rule_description = {{ detection.deployment.alert_action.notable.rule_description | custom_jinja2_enrichment_filter(detection) | escapeNewlines()}} action.notable.param.rule_title = {% if detection.type | lower == "correlation" %}RBA: {{ detection.deployment.alert_action.notable.rule_title | custom_jinja2_enrichment_filter(detection) }}{% else %}{{ detection.deployment.alert_action.notable.rule_title | custom_jinja2_enrichment_filter(detection) }}{% endif +%} action.notable.param.security_domain = {{ detection.tags.security_domain.value }} -action.notable.param.severity = high +action.notable.param.severity = {{ detection.tags.severity.value }} {% endif %} {% if detection.deployment.alert_action.email %} action.email.subject.alert = {{ detection.deployment.alert_action.email.subject | custom_jinja2_enrichment_filter(detection) | escapeNewlines() }} From 954794053fc9c969ecd9c6e03c63b1d80840aaa5 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Tue, 16 Jul 2024 15:48:12 -0700 Subject: [PATCH 15/46] Improve annotated strings that were defined incorrectly. Change from throttling to alert_suppression since that is the name used in splunk+documentation. Update template to output the field if it is defined. --- contentctl/enrichments/cve_enrichment.py | 2 +- .../detection_abstract.py | 25 ++++++++++++++ contentctl/objects/alert_suppression.py | 33 +++++++++++++++++++ contentctl/objects/detection_tags.py | 5 +-- contentctl/objects/story_tags.py | 2 +- .../templates/savedsearches_detections.j2 | 5 +++ 6 files changed, 68 insertions(+), 4 deletions(-) create mode 100644 contentctl/objects/alert_suppression.py diff --git a/contentctl/enrichments/cve_enrichment.py b/contentctl/enrichments/cve_enrichment.py index eb426623..54089487 100644 --- a/contentctl/enrichments/cve_enrichment.py +++ b/contentctl/enrichments/cve_enrichment.py @@ -18,7 +18,7 @@ class CveEnrichmentObj(BaseModel): - id:Annotated[str, "^CVE-[1|2][0-9]{3}-[0-9]+$"] + id:Annotated[str, Field(pattern="^CVE-[1|2][0-9]{3}-[0-9]+$")] cvss:Annotated[Decimal, Field(ge=.1, le=10, decimal_places=1)] summary:str diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index 8389ad78..eacf495d 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -547,6 +547,31 @@ def addTags_nist(self): self.tags.nist = [NistCategory.DE_AE] return self + + @model_validator(mode="after") + def ensureThrottlingFieldsExist(self): + ''' + For throttling to work properly, the fields to throttle on MUST + exist in the search itself. If not, then we cannot apply the throttling + ''' + if self.tags.alert_suppression is None: + # No throttling configured for this detection + return self + + if not isinstance(self.search, str): + # Search is sigma-formatted, so we cannot perform this validation. + return self + + missing_fields:list[str] = [field for field in self.tags.alert_suppression.fields if field not in self.search] + if len(missing_fields) > 0: + raise ValueError(f"The following throttle fields were missing from the search: {missing_fields}") + + else: + # All throttling fields present in search + return self + + + @model_validator(mode="after") def ensureProperObservablesExist(self): """ diff --git a/contentctl/objects/alert_suppression.py b/contentctl/objects/alert_suppression.py new file mode 100644 index 00000000..83359e70 --- /dev/null +++ b/contentctl/objects/alert_suppression.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel, Field +from typing import Annotated + +# MAximum throttling window defined as once per day. +MAX_THROTTLING_WINDOW = 60 * 60 * 24 + +# Alert Suppression/Throttling settings have been taken from +# https://docs.splunk.com/Documentation/Splunk/9.2.2/Admin/Savedsearchesconf +class AlertSuppression(BaseModel): + fields: list[str] = Field(..., description="The list of fields to throttle on. These fields MUST occur in the search.", min_length=1) + period: Annotated[str,Field(pattern="^[0-9]+[smh]$")] = Field(..., description="How often the alert should be triggered. " + "This may be specified in seconds, minutes, or hours. " + "For example, if an alert should be triggered once a day," + " it may be specified in seconds (86400s), minutes (1440m), or hours import (24h).") + + + + def conf_formatted_fields(self)->str: + ''' + The field alert.suppress.fields is defined as follows: + alert.suppress.fields = + * List of fields to use when suppressing per-result alerts. This field *must* + be specified if the digest mode is disabled and suppression is enabled. + + In order to support fields with spaces in them, we must also wrap each + field in "". + This function returns a properly formatted value, where each field + is wrapped in "" and separated with a comma. For example, the fields + ["field1", "field 2", "field3"] would be returned as the string + + "field1","field 2","field3 + ''' + return ",".join([f'"{field}"' for field in self.fields]) \ No newline at end of file diff --git a/contentctl/objects/detection_tags.py b/contentctl/objects/detection_tags.py index 87667c29..3dd0453a 100644 --- a/contentctl/objects/detection_tags.py +++ b/contentctl/objects/detection_tags.py @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING, List, Optional, Annotated, Union from pydantic import BaseModel,Field, NonNegativeInt, PositiveInt, computed_field, UUID4, HttpUrl, ConfigDict, field_validator, ValidationInfo, model_serializer, model_validator from contentctl.objects.story import Story +from contentctl.objects.alert_suppression import AlertSuppression if TYPE_CHECKING: from contentctl.input.director import DirectorOutputDto @@ -37,7 +38,7 @@ def risk_score(self)->int: message: Optional[str] = Field(...) product: list[SecurityContentProductName] = Field(...,min_length=1) required_fields: list[str] = Field(min_length=1) - + alert_suppression: Optional[AlertSuppression] = None security_domain: SecurityDomain = Field(...) @computed_field @@ -52,7 +53,7 @@ def risk_severity(self)->RiskSeverity: - cve: List[Annotated[str, "^CVE-[1|2][0-9]{3}-[0-9]+$"]] = [] + cve: List[Annotated[str, Field(pattern="^CVE-[1|2][0-9]{3}-[0-9]+$")]] = [] atomic_guid: List[AtomicTest] = [] drilldown_search: Optional[str] = None diff --git a/contentctl/objects/story_tags.py b/contentctl/objects/story_tags.py index 0dfc0fd7..7c60e48c 100644 --- a/contentctl/objects/story_tags.py +++ b/contentctl/objects/story_tags.py @@ -28,7 +28,7 @@ class StoryTags(BaseModel): mitre_attack_tactics: Optional[Set[Annotated[str, Field(pattern="^T\d{4}(.\d{3})?$")]]] = None datamodels: Optional[Set[DataModel]] = None kill_chain_phases: Optional[Set[KillChainPhase]] = None - cve: List[Annotated[str, "^CVE-[1|2][0-9]{3}-[0-9]+$"]] = [] + cve: List[Annotated[str, Field(pattern="^CVE-[1|2][0-9]{3}-[0-9]+$")]] = [] group: List[str] = Field([], description="A list of groups who leverage the techniques list in this Analytic Story.") def getCategory_conf(self) -> str: diff --git a/contentctl/output/templates/savedsearches_detections.j2 b/contentctl/output/templates/savedsearches_detections.j2 index 92db3833..8291eaf7 100644 --- a/contentctl/output/templates/savedsearches_detections.j2 +++ b/contentctl/output/templates/savedsearches_detections.j2 @@ -115,6 +115,11 @@ relation = greater than quantity = 0 realtime_schedule = 0 is_visible = false +{% if detection.tags.alert_suppression %} +alert.suppress = true +alert.suppress.fields = {{ detection.tags.alert_suppression.conf_formatted_fields() }} +alert.suppress.period = {{ detection.tags.alert_suppression.period }} +{% endif %} search = {{ detection.search | escapeNewlines() }} {% endif %} From 2f9bcf6628d87d14f0c92a748bfb9d0b0188e21e Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Tue, 16 Jul 2024 16:14:46 -0700 Subject: [PATCH 16/46] Remove ui_dispatch_app from conf files, but keep other architectural changes in this PR. --- contentctl/objects/config.py | 5 ----- contentctl/output/templates/savedsearches_baselines.j2 | 1 - contentctl/output/templates/savedsearches_detections.j2 | 1 - contentctl/output/templates/savedsearches_investigations.j2 | 1 - 4 files changed, 8 deletions(-) diff --git a/contentctl/objects/config.py b/contentctl/objects/config.py index 7dbaab52..45f035d1 100644 --- a/contentctl/objects/config.py +++ b/contentctl/objects/config.py @@ -148,11 +148,6 @@ def getApp(self, config:test, stage_file=True)->str: verbose_print=True) return str(destination) - def get_ui_dispatch_app(self)->str: - ''' - Populate the request.ui_dispatch_app field in savedsearches.conf - ''' - return "SplunkEnterpriseSecuritySuite" class Config_Base(BaseModel): diff --git a/contentctl/output/templates/savedsearches_baselines.j2 b/contentctl/output/templates/savedsearches_baselines.j2 index 9557115e..a4ae29f1 100644 --- a/contentctl/output/templates/savedsearches_baselines.j2 +++ b/contentctl/output/templates/savedsearches_baselines.j2 @@ -43,7 +43,6 @@ disabled = true {% endif %} is_visible = false search = {{ detection.search | escapeNewlines() }} -request.ui_dispatch_app = {{ app.get_ui_dispatch_app() }} {% endif %} {% endfor %} diff --git a/contentctl/output/templates/savedsearches_detections.j2 b/contentctl/output/templates/savedsearches_detections.j2 index 39d5c630..69e0bce6 100644 --- a/contentctl/output/templates/savedsearches_detections.j2 +++ b/contentctl/output/templates/savedsearches_detections.j2 @@ -116,7 +116,6 @@ quantity = 0 realtime_schedule = 0 is_visible = false search = {{ detection.search | escapeNewlines() }} -request.ui_dispatch_app = {{ app.get_ui_dispatch_app() }} {% endif %} {% endfor %} diff --git a/contentctl/output/templates/savedsearches_investigations.j2 b/contentctl/output/templates/savedsearches_investigations.j2 index 98b7304c..8c2038ea 100644 --- a/contentctl/output/templates/savedsearches_investigations.j2 +++ b/contentctl/output/templates/savedsearches_investigations.j2 @@ -29,7 +29,6 @@ disabled = true schedule_window = auto is_visible = false search = {{ detection.search | escapeNewlines() }} -request.ui_dispatch_app = {{ app.get_ui_dispatch_app() }} {% endif %} {% endif %} From 0a01e06e3c02d32c43353aea9cdea10b0d5c779d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 18 Jul 2024 05:23:38 +0000 Subject: [PATCH 17/46] Update setuptools requirement from >=69.5.1,<71.0.0 to >=69.5.1,<72.0.0 Updates the requirements on [setuptools](https://github.com/pypa/setuptools) to permit the latest version. - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/setuptools/compare/v69.5.1...v71.0.1) --- updated-dependencies: - dependency-name: setuptools dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 817a3c0c..8d16c732 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ pysigma-backend-splunk = "^1.1.0" pygit2 = "^1.14.1" tyro = "^0.8.3" gitpython = "^3.1.43" -setuptools = ">=69.5.1,<71.0.0" +setuptools = ">=69.5.1,<72.0.0" [tool.poetry.dev-dependencies] [build-system] From a30a9029ea53bb7310f8b5a0c8ef480801d511cb Mon Sep 17 00:00:00 2001 From: James Hodgkinson Date: Fri, 19 Jul 2024 14:16:25 +1000 Subject: [PATCH 18/46] handling the case where there are no tests --- .../detection_abstract.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index 8389ad78..0b2c3c2b 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -120,13 +120,16 @@ def validate_test_groups(cls, value:Union[None, List[TestGroup]], info:Validatio # iterate over the unit tests and create a TestGroup (and as a result, an IntegrationTest) for each test_groups: list[TestGroup] = [] - for unit_test in info.data.get("tests"): - test_group = TestGroup.derive_from_unit_test(unit_test, info.data.get("name")) + + tests = info.data.get("tests", []) + for unit_test in tests: + test_group = TestGroup.derive_from_unit_test(unit_test, info.data["name"]) test_groups.append(test_group) # now add each integration test to the list of tests for test_group in test_groups: - info.data.get("tests").append(test_group.integration_test) + tests.append(test_group.integration_test) + info.data['tests'] = tests return test_groups From 858a05078641b239bc4e02d827dd90042e07b951 Mon Sep 17 00:00:00 2001 From: James Hodgkinson Date: Fri, 19 Jul 2024 14:37:26 +1000 Subject: [PATCH 19/46] handling the case where there are no tests --- contentctl/actions/new_content.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/contentctl/actions/new_content.py b/contentctl/actions/new_content.py index 6c155bd0..e9a4bab0 100644 --- a/contentctl/actions/new_content.py +++ b/contentctl/actions/new_content.py @@ -71,7 +71,9 @@ def buildDetection(self)->dict[str,Any]: def buildStory(self)->dict[str,Any]: questions = NewContentQuestions.get_questions_story() - answers = questionary.prompt(questions) + answers = questionary.prompt(questions, kbi_msg="User did not answer all of the prompt questions. Exiting...") + if not answers: + raise ValueError("User didn't answer one or more questions!") answers['name'] = answers['story_name'] del answers['story_name'] answers['id'] = str(uuid.uuid4()) From 5200a82e260eabf9933ba80a02429a35bb372ca5 Mon Sep 17 00:00:00 2001 From: James Hodgkinson Date: Fri, 19 Jul 2024 14:45:03 +1000 Subject: [PATCH 20/46] typing fixes --- contentctl/actions/new_content.py | 10 ++++++++-- contentctl/input/new_content_questions.py | 7 +++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/contentctl/actions/new_content.py b/contentctl/actions/new_content.py index e9a4bab0..0ccc349f 100644 --- a/contentctl/actions/new_content.py +++ b/contentctl/actions/new_content.py @@ -16,7 +16,11 @@ class NewContent: def buildDetection(self)->dict[str,Any]: questions = NewContentQuestions.get_questions_detection() - answers = questionary.prompt(questions) + answers: dict[str,str] = questionary.prompt( + questions, + kbi_msg="User did not answer all of the prompt questions. Exiting...") + if not answers: + raise ValueError("User didn't answer one or more questions!") answers.update(answers) answers['name'] = answers['detection_name'] del answers['detection_name'] @@ -71,7 +75,9 @@ def buildDetection(self)->dict[str,Any]: def buildStory(self)->dict[str,Any]: questions = NewContentQuestions.get_questions_story() - answers = questionary.prompt(questions, kbi_msg="User did not answer all of the prompt questions. Exiting...") + answers = questionary.prompt( + questions, + kbi_msg="User did not answer all of the prompt questions. Exiting...") if not answers: raise ValueError("User didn't answer one or more questions!") answers['name'] = answers['story_name'] diff --git a/contentctl/input/new_content_questions.py b/contentctl/input/new_content_questions.py index dc450936..0bd227d4 100644 --- a/contentctl/input/new_content_questions.py +++ b/contentctl/input/new_content_questions.py @@ -1,7 +1,10 @@ +from typing import Any + + class NewContentQuestions: @classmethod - def get_questions_detection(self) -> list: + def get_questions_detection(cls) -> list[dict[str,Any]]: questions = [ { "type": "text", @@ -116,7 +119,7 @@ def get_questions_detection(self) -> list: return questions @classmethod - def get_questions_story(self) -> list: + def get_questions_story(cls)-> list[dict[str,Any]]: questions = [ { "type": "text", From 699456477792e185195ff440dad43c9111d51703 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Jul 2024 05:51:55 +0000 Subject: [PATCH 21/46] Update setuptools requirement from >=69.5.1,<71.0.0 to >=69.5.1,<72.0.0 Updates the requirements on [setuptools](https://github.com/pypa/setuptools) to permit the latest version. - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/setuptools/compare/v69.5.1...v71.1.0) --- updated-dependencies: - dependency-name: setuptools dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 5b0d74cc..2f8aa1fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ pysigma-backend-splunk = "^1.1.0" pygit2 = "^1.14.1" tyro = "^0.8.3" gitpython = "^3.1.43" -setuptools = ">=69.5.1,<71.0.0" +setuptools = ">=69.5.1,<72.0.0" [tool.poetry.dev-dependencies] [build-system] From a9b09e8b01aa89e8e1b0eb0269ead399c8ecccf8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 26 Jul 2024 17:03:50 +0000 Subject: [PATCH 22/46] Update setuptools requirement from >=69.5.1,<71.0.0 to >=69.5.1,<72.0.0 Updates the requirements on [setuptools](https://github.com/pypa/setuptools) to permit the latest version. - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/setuptools/compare/v69.5.1...v71.1.0) --- updated-dependencies: - dependency-name: setuptools dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fe7632ef..94fd914c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ pysigma-backend-splunk = "^1.1.0" pygit2 = "^1.14.1" tyro = "^0.8.3" gitpython = "^3.1.43" -setuptools = ">=69.5.1,<71.0.0" +setuptools = ">=69.5.1,<72.0.0" [tool.poetry.dev-dependencies] [build-system] From eb0813f782830763aeca3de5c57b5910f3db3126 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Fri, 26 Jul 2024 13:45:23 -0700 Subject: [PATCH 23/46] Remove extra validator that was a duplicate of functionality in another validator. --- .../detection_abstract.py | 34 ------------------- 1 file changed, 34 deletions(-) diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index 17c8c391..6f35d9cb 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -663,40 +663,6 @@ def ensurePresenceOfRequiredTests(self): return self - @field_validator("tests") - def tests_validate(cls, v, info:ValidationInfo): - # TODO (cmcginley): Fix detection_abstract.tests_validate so that it surfaces validation errors - # (e.g. a lack of tests) to the final results, instead of just showing a failed detection w/ - # no tests (maybe have a message propagated at the detection level? do a separate coverage - # check as part of validation?): - - - #Only production analytics require tests - if info.data.get("status","") != DetectionStatus.production.value: - return v - - # All types EXCEPT Correlation MUST have test(s). Any other type, including newly defined types, requires them. - # Accordingly, we do not need to do additional checks if the type is Correlation - if info.data.get("type","") in set([AnalyticsType.Correlation.value]): - return v - - - # Ensure that there is at least 1 test - if len(v) == 0: - if info.data.get("tags",None) and info.data.get("tags").manual_test is not None: - # Detections that are manual_test MAY have detections, but it is not required. If they - # do not have one, then create one which will be a placeholder. - # Note that this fake UnitTest (and by extension, Integration Test) will NOT be generated - # if there ARE test(s) defined for a Detection. - placeholder_test = UnitTest(name="PLACEHOLDER FOR DETECTION TAGGED MANUAL_TEST WITH NO TESTS SPECIFIED IN YML FILE", attack_data=[]) - return [placeholder_test] - - else: - raise ValueError("At least one test is REQUIRED for production detection: " + info.data.get("name", "NO NAME FOUND")) - - - #No issues - at least one test provided for production type requiring testing - return v def all_tests_successful(self) -> bool: """ From b8b4f5b604ab5e7bcf5e5959ed4e32a3c2537585 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Jul 2024 05:37:57 +0000 Subject: [PATCH 24/46] Update setuptools requirement from >=69.5.1,<71.0.0 to >=69.5.1,<73.0.0 Updates the requirements on [setuptools](https://github.com/pypa/setuptools) to permit the latest version. - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/setuptools/compare/v69.5.1...v72.0.0) --- updated-dependencies: - dependency-name: setuptools dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fe7632ef..141e5322 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ pysigma-backend-splunk = "^1.1.0" pygit2 = "^1.14.1" tyro = "^0.8.3" gitpython = "^3.1.43" -setuptools = ">=69.5.1,<71.0.0" +setuptools = ">=69.5.1,<73.0.0" [tool.poetry.dev-dependencies] [build-system] From 76a2b146fff9ce51d83f4934021c0b565d4af42f Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Mon, 29 Jul 2024 12:39:45 -0700 Subject: [PATCH 25/46] Improving validations on name and on field generation for conf files --- .../detection_abstract.py | 26 ++++++++++++++++++- contentctl/objects/baseline.py | 2 +- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index c9e4a87c..20f887cc 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -24,6 +24,7 @@ from contentctl.objects.integration_test import IntegrationTest from contentctl.objects.event_source import EventSource from contentctl.objects.data_source import DataSource +from contentctl.objects.config import CustomApp #from contentctl.objects.playbook import Playbook from contentctl.objects.enums import ProvidingTechnology @@ -33,7 +34,7 @@ class Detection_Abstract(SecurityContentObject): model_config = ConfigDict(use_enum_values=True) - + name:str = Field(...,max_length=67) #contentType: SecurityContentType = SecurityContentType.detections type: AnalyticsType = Field(...) status: DetectionStatus = Field(...) @@ -56,6 +57,28 @@ class Detection_Abstract(SecurityContentObject): data_source_objects: list[DataSource] = [] + def get_action_dot_correlationsearch_dot_label(self, app:CustomApp, max_stanza_length:int=99)->str: + if self.status != DetectionStatus.production.value: + label = f"{app.label} - {self.status.value.capitalize()} - {self.name} - Rule" + else: + label = self.get_conf_stanza_name(app) + + label_after_saving_in_product = f"{self.tags.security_domain} - {label} - Rule" + if len(label_after_saving_in_product) > max_stanza_length: + raise ValueError(f"label may only be {max_stanza_length} characters to allow updating in-product, " + f"but stanza was actually {len(label_after_saving_in_product)} characters: '{len}' ") + + return label + + def get_conf_stanza_name(self, app:CustomApp, max_stanza_length:int=80)->str: + stanza_name = f"{app.label} - {self.name} - Rule" + if len(stanza_name) > 80: + raise ValueError(f"conf stanza may only be {max_stanza_length} characters, " + f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' ") + return stanza_name + + + @field_validator("search", mode="before") @classmethod @@ -371,6 +394,7 @@ def serialize_model(self): def model_post_init(self, ctx:dict[str,Any]): + print(f"\nLENGTH: {len(self.name)}\n") # director: Optional[DirectorOutputDto] = ctx.get("output_dto",None) # if not isinstance(director,DirectorOutputDto): # raise ValueError("DirectorOutputDto was not passed in context of Detection model_post_init") diff --git a/contentctl/objects/baseline.py b/contentctl/objects/baseline.py index ee9e66bf..5c9e3557 100644 --- a/contentctl/objects/baseline.py +++ b/contentctl/objects/baseline.py @@ -19,7 +19,7 @@ class Baseline(SecurityContentObject): # baseline spec - #name: str + name:str = Field(...,max_length=67) #id: str #version: int #date: str From 7f795532b655c25e4a2a387a63da31cbbbcc4801 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Tue, 30 Jul 2024 09:37:33 -0700 Subject: [PATCH 26/46] Name length improvements. this is still DRAFT --- .../detection_abstract.py | 28 +++++++++++++------ .../security_content_object_abstract.py | 2 +- .../templates/savedsearches_detections.j2 | 12 ++------ 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index 05239348..da304606 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -10,6 +10,7 @@ if TYPE_CHECKING: from contentctl.input.director import DirectorOutputDto from contentctl.objects.baseline import Baseline + from contentctl.objects.config import CustomApp from contentctl.objects.security_content_object import SecurityContentObject from contentctl.objects.enums import AnalyticsType @@ -24,7 +25,7 @@ from contentctl.objects.integration_test import IntegrationTest from contentctl.objects.event_source import EventSource from contentctl.objects.data_source import DataSource -from contentctl.objects.config import CustomApp + #from contentctl.objects.playbook import Playbook from contentctl.objects.enums import ProvidingTechnology @@ -59,22 +60,33 @@ class Detection_Abstract(SecurityContentObject): def get_action_dot_correlationsearch_dot_label(self, app:CustomApp, max_stanza_length:int=99)->str: if self.status != DetectionStatus.production.value: - label = f"{app.label} - {self.status.value.capitalize()} - {self.name} - Rule" + label = f"{app.label} - {self.status.capitalize()} - {self.name} - Rule" + elif self.type == AnalyticsType.Correlation.value: + label = f"{app.label} - RIR - {self.name} - Rule" else: label = self.get_conf_stanza_name(app) + - label_after_saving_in_product = f"{self.tags.security_domain} - {label} - Rule" - if len(label_after_saving_in_product) > max_stanza_length: - raise ValueError(f"label may only be {max_stanza_length} characters to allow updating in-product, " - f"but stanza was actually {len(label_after_saving_in_product)} characters: '{len}' ") + label = self.get_conf_stanza_name(app) + label_after_saving_in_product = f"{self.tags.security_domain.value} - {label} - Rule" + + if len(label_after_saving_in_product) > max_stanza_length+1: + raise ValueError(f"label may only be {max_stanza_length} characters to allow updating in-product, " + f"but stanza was actually {len(label_after_saving_in_product)} characters: '{label_after_saving_in_product}' ") + #if len(label_after_saving_in_product)>98: + # print(f"\n\n{label}\n\n") + #print(f"CorrelationSearch Length[{len(label_after_saving_in_product)}]") + + return label def get_conf_stanza_name(self, app:CustomApp, max_stanza_length:int=80)->str: stanza_name = f"{app.label} - {self.name} - Rule" - if len(stanza_name) > 80: + if len(stanza_name) > 100: raise ValueError(f"conf stanza may only be {max_stanza_length} characters, " f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' ") + #print(f"Stanza Length[{len(stanza_name)}]") return stanza_name @@ -134,6 +146,7 @@ def validate_presence_of_filter_macro(cls, value:Union[str, dict[str,Any]], info @field_validator("test_groups") @classmethod def validate_test_groups(cls, value:Union[None, List[TestGroup]], info:ValidationInfo) -> Union[List[TestGroup], None]: + return [] """ Validates the `test_groups` field and constructs the model from the list of unit tests if no explicit construct was provided @@ -397,7 +410,6 @@ def serialize_model(self): def model_post_init(self, ctx:dict[str,Any]): - print(f"\nLENGTH: {len(self.name)}\n") # director: Optional[DirectorOutputDto] = ctx.get("output_dto",None) # if not isinstance(director,DirectorOutputDto): # raise ValueError("DirectorOutputDto was not passed in context of Detection model_post_init") diff --git a/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py b/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py index 430872be..9df83ccb 100644 --- a/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py @@ -26,7 +26,7 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC): model_config = ConfigDict(use_enum_values=True,validate_default=True) - # name: str = ... + #name:str = Field(...,max_length=50) # author: str = Field(...,max_length=255) # date: datetime.date = Field(...) # version: NonNegativeInt = ... diff --git a/contentctl/output/templates/savedsearches_detections.j2 b/contentctl/output/templates/savedsearches_detections.j2 index 69e0bce6..08f2b9cf 100644 --- a/contentctl/output/templates/savedsearches_detections.j2 +++ b/contentctl/output/templates/savedsearches_detections.j2 @@ -2,7 +2,7 @@ {% for detection in objects %} {% if (detection.type == 'TTP' or detection.type == 'Anomaly' or detection.type == 'Hunting' or detection.type == 'Correlation') %} -[{{app.label}} - {{ detection.name }} - Rule] +[{{ detection.get_conf_stanza_name(app) }}] action.escu = 0 action.escu.enabled = 1 {% if detection.status == "deprecated" %} @@ -57,15 +57,7 @@ cron_schedule = {{ detection.deployment.scheduling.cron_schedule }} dispatch.earliest_time = {{ detection.deployment.scheduling.earliest_time }} dispatch.latest_time = {{ detection.deployment.scheduling.latest_time }} action.correlationsearch.enabled = 1 -{% if detection.status == "deprecated" %} -action.correlationsearch.label = {{app.label}} - Deprecated - {{ detection.name }} - Rule -{% elif detection.status == "experimental" %} -action.correlationsearch.label = {{app.label}} - Experimental - {{ detection.name }} - Rule -{% elif detection.type | lower == "correlation" %} -action.correlationsearch.label = {{app.label}} - RIR - {{ detection.name }} - Rule -{% else %} -action.correlationsearch.label = {{app.label}} - {{ detection.name }} - Rule -{% endif %} +action.correlationsearch.label = {{ detection.get_action_dot_correlationsearch_dot_label(app) }} action.correlationsearch.annotations = {{ detection.annotations | tojson }} action.correlationsearch.metadata = {{ detection.getMetadata() | tojson }} {% if detection.deployment.scheduling.schedule_window is defined %} From 50b302d63350f0d9293704212ef0a0cd4c9adebe Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Tue, 30 Jul 2024 17:05:06 -0700 Subject: [PATCH 27/46] Fix issue with name length at scale --- .../detection_abstract.py | 46 ++++++------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index da304606..6ccf3582 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -59,25 +59,12 @@ class Detection_Abstract(SecurityContentObject): data_source_objects: list[DataSource] = [] def get_action_dot_correlationsearch_dot_label(self, app:CustomApp, max_stanza_length:int=99)->str: - if self.status != DetectionStatus.production.value: - label = f"{app.label} - {self.status.capitalize()} - {self.name} - Rule" - elif self.type == AnalyticsType.Correlation.value: - label = f"{app.label} - RIR - {self.name} - Rule" - else: - label = self.get_conf_stanza_name(app) - - label = self.get_conf_stanza_name(app) - label_after_saving_in_product = f"{self.tags.security_domain.value} - {label} - Rule" - if len(label_after_saving_in_product) > max_stanza_length+1: + if len(label_after_saving_in_product) > max_stanza_length: raise ValueError(f"label may only be {max_stanza_length} characters to allow updating in-product, " f"but stanza was actually {len(label_after_saving_in_product)} characters: '{label_after_saving_in_product}' ") - #if len(label_after_saving_in_product)>98: - # print(f"\n\n{label}\n\n") - #print(f"CorrelationSearch Length[{len(label_after_saving_in_product)}]") - return label @@ -143,33 +130,24 @@ def validate_presence_of_filter_macro(cls, value:Union[str, dict[str,Any]], info - @field_validator("test_groups") - @classmethod - def validate_test_groups(cls, value:Union[None, List[TestGroup]], info:ValidationInfo) -> Union[List[TestGroup], None]: - return [] + def add_test_groups(self)->None: """ Validates the `test_groups` field and constructs the model from the list of unit tests if no explicit construct was provided :param value: the value of the field `test_groups` :param values: a dict of the other fields in the Detection model """ - # if the value was not the None default, do nothing - if value is not None: - return value - - # iterate over the unit tests and create a TestGroup (and as a result, an IntegrationTest) for each - test_groups: list[TestGroup] = [] - - tests = info.data.get("tests", []) - for unit_test in tests: - test_group = TestGroup.derive_from_unit_test(unit_test, info.data["name"]) + test_groups:list[TestGroup] = [] + for unit_test in self.tests: + if not isinstance(unit_test, UnitTest): + raise ValueError(f"Expected type of UnitTest, but found {type(unit_test)} instead.") + test_group = TestGroup.derive_from_unit_test(unit_test, self.name) test_groups.append(test_group) # now add each integration test to the list of tests for test_group in test_groups: - tests.append(test_group.integration_test) - info.data['tests'] = tests - return test_groups + self.tests.append(test_group.integration_test) + @computed_field @@ -459,7 +437,11 @@ def model_post_init(self, ctx:dict[str,Any]): self.data_source_objects = matched_data_sources for story in self.tags.analytic_story: - story.detections.append(self) + story.detections.append(self) + + + #self.add_test_groups() + return self From 3a08a72a87be3e4efe7dbc098a558caa4dc55f3c Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Wed, 31 Jul 2024 15:46:20 -0700 Subject: [PATCH 28/46] improvements to how name is gotten for savedsearches.conf. DRAFT - check about test validation for manual_Test --- .../detection_abstract.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index 6ccf3582..48a3ec8c 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -54,7 +54,7 @@ class Detection_Abstract(SecurityContentObject): # https://github.com/pydantic/pydantic/issues/9101#issuecomment-2019032541 tests: List[Annotated[Union[UnitTest, IntegrationTest], Field(union_mode='left_to_right')]] = [] # A list of groups of tests, relying on the same data - test_groups: Union[list[TestGroup], None] = Field(None,validate_default=True) + test_groups: list[TestGroup] = [] data_source_objects: list[DataSource] = [] @@ -68,9 +68,9 @@ def get_action_dot_correlationsearch_dot_label(self, app:CustomApp, max_stanza_l return label - def get_conf_stanza_name(self, app:CustomApp, max_stanza_length:int=80)->str: + def get_conf_stanza_name(self, app:CustomApp, max_stanza_length:int=81)->str: stanza_name = f"{app.label} - {self.name} - Rule" - if len(stanza_name) > 100: + if len(stanza_name) > max_stanza_length: raise ValueError(f"conf stanza may only be {max_stanza_length} characters, " f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' ") #print(f"Stanza Length[{len(stanza_name)}]") @@ -137,6 +137,7 @@ def add_test_groups(self)->None: :param value: the value of the field `test_groups` :param values: a dict of the other fields in the Detection model """ + test_groups:list[TestGroup] = [] for unit_test in self.tests: if not isinstance(unit_test, UnitTest): @@ -147,6 +148,7 @@ def add_test_groups(self)->None: # now add each integration test to the list of tests for test_group in test_groups: self.tests.append(test_group.integration_test) + self.test_groups = test_groups @@ -440,7 +442,7 @@ def model_post_init(self, ctx:dict[str,Any]): story.detections.append(self) - #self.add_test_groups() + self.add_test_groups() return self @@ -674,6 +676,8 @@ def ensurePresenceOfRequiredTests(self): if self.tags.manual_test is not None: for test in self.tests: test.skip(f"TEST SKIPPED: Detection marked as 'manual_test' with explanation: '{self.tags.manual_test}'") + print("MANUAL TEST with no tests - are we supposed to create a placeholder here?") + return self if len(self.tests) == 0: raise ValueError(f"At least one test is REQUIRED for production detection: {self.name}") From 75733070ed07f7ab84044badce6ee574537a48b0 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Thu, 1 Aug 2024 11:06:10 -0700 Subject: [PATCH 29/46] change AlertSuppression to Throttling since that name is more common and the one used in the User Interface --- .../detection_abstract.py | 4 +-- contentctl/objects/detection_tags.py | 4 +-- .../{alert_suppression.py => throttling.py} | 27 ++++++++++++++----- .../templates/savedsearches_detections.j2 | 6 ++--- 4 files changed, 27 insertions(+), 14 deletions(-) rename contentctl/objects/{alert_suppression.py => throttling.py} (60%) diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index a682a3c8..5015e81f 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -579,7 +579,7 @@ def ensureThrottlingFieldsExist(self): For throttling to work properly, the fields to throttle on MUST exist in the search itself. If not, then we cannot apply the throttling ''' - if self.tags.alert_suppression is None: + if self.tags.throttling is None: # No throttling configured for this detection return self @@ -587,7 +587,7 @@ def ensureThrottlingFieldsExist(self): # Search is sigma-formatted, so we cannot perform this validation. return self - missing_fields:list[str] = [field for field in self.tags.alert_suppression.fields if field not in self.search] + missing_fields:list[str] = [field for field in self.tags.throttling.fields if field not in self.search] if len(missing_fields) > 0: raise ValueError(f"The following throttle fields were missing from the search: {missing_fields}") diff --git a/contentctl/objects/detection_tags.py b/contentctl/objects/detection_tags.py index 3dd0453a..3740f5c9 100644 --- a/contentctl/objects/detection_tags.py +++ b/contentctl/objects/detection_tags.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING, List, Optional, Annotated, Union from pydantic import BaseModel,Field, NonNegativeInt, PositiveInt, computed_field, UUID4, HttpUrl, ConfigDict, field_validator, ValidationInfo, model_serializer, model_validator from contentctl.objects.story import Story -from contentctl.objects.alert_suppression import AlertSuppression +from contentctl.objects.throttling import Throttling if TYPE_CHECKING: from contentctl.input.director import DirectorOutputDto @@ -38,7 +38,7 @@ def risk_score(self)->int: message: Optional[str] = Field(...) product: list[SecurityContentProductName] = Field(...,min_length=1) required_fields: list[str] = Field(min_length=1) - alert_suppression: Optional[AlertSuppression] = None + throttling: Optional[Throttling] = None security_domain: SecurityDomain = Field(...) @computed_field diff --git a/contentctl/objects/alert_suppression.py b/contentctl/objects/throttling.py similarity index 60% rename from contentctl/objects/alert_suppression.py rename to contentctl/objects/throttling.py index 83359e70..04998ac6 100644 --- a/contentctl/objects/alert_suppression.py +++ b/contentctl/objects/throttling.py @@ -1,33 +1,46 @@ -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator from typing import Annotated -# MAximum throttling window defined as once per day. -MAX_THROTTLING_WINDOW = 60 * 60 * 24 # Alert Suppression/Throttling settings have been taken from # https://docs.splunk.com/Documentation/Splunk/9.2.2/Admin/Savedsearchesconf -class AlertSuppression(BaseModel): +class Throttling(BaseModel): fields: list[str] = Field(..., description="The list of fields to throttle on. These fields MUST occur in the search.", min_length=1) period: Annotated[str,Field(pattern="^[0-9]+[smh]$")] = Field(..., description="How often the alert should be triggered. " "This may be specified in seconds, minutes, or hours. " "For example, if an alert should be triggered once a day," " it may be specified in seconds (86400s), minutes (1440m), or hours import (24h).") - + @field_validator("fields") + def no_spaces_in_fields(cls, v:list[str])->list[str]: + for field in v: + if ' ' in field: + raise ValueError("Spaces are not presently supported in 'alert.suppress.fields' / throttling fields in conf files. " + "The field '{field}' has a space in it. If this is a blocker, please raise this as an issue on the Project.") + return v def conf_formatted_fields(self)->str: ''' + TODO: The field alert.suppress.fields is defined as follows: alert.suppress.fields = * List of fields to use when suppressing per-result alerts. This field *must* be specified if the digest mode is disabled and suppression is enabled. - In order to support fields with spaces in them, we must also wrap each + In order to support fields with spaces in them, we may need to wrap each field in "". This function returns a properly formatted value, where each field is wrapped in "" and separated with a comma. For example, the fields ["field1", "field 2", "field3"] would be returned as the string "field1","field 2","field3 + + However, for now, we will error on fields with spaces and simply + separate with commas ''' - return ",".join([f'"{field}"' for field in self.fields]) \ No newline at end of file + + return ",".join(self.fields) + + # The following may be used once we determine proper support + # for fields with spaces + #return ",".join([f'"{field}"' for field in self.fields]) \ No newline at end of file diff --git a/contentctl/output/templates/savedsearches_detections.j2 b/contentctl/output/templates/savedsearches_detections.j2 index 8a1749e7..91a5b203 100644 --- a/contentctl/output/templates/savedsearches_detections.j2 +++ b/contentctl/output/templates/savedsearches_detections.j2 @@ -115,10 +115,10 @@ relation = greater than quantity = 0 realtime_schedule = 0 is_visible = false -{% if detection.tags.alert_suppression %} +{% if detection.tags.throttling %} alert.suppress = true -alert.suppress.fields = {{ detection.tags.alert_suppression.conf_formatted_fields() }} -alert.suppress.period = {{ detection.tags.alert_suppression.period }} +alert.suppress.fields = {{ detection.tags.throttling.conf_formatted_fields() }} +alert.suppress.period = {{ detection.tags.throttling.period }} {% endif %} search = {{ detection.search | escapeNewlines() }} From d0e0a2979720ac505d7ec08419d95d9f58089bfa Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Thu, 1 Aug 2024 17:00:49 -0700 Subject: [PATCH 30/46] split json for dashboard into a separate file --- contentctl/objects/dashboard.py | 40 ++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/contentctl/objects/dashboard.py b/contentctl/objects/dashboard.py index 20196add..db221806 100644 --- a/contentctl/objects/dashboard.py +++ b/contentctl/objects/dashboard.py @@ -33,38 +33,46 @@ class Dashboard(SecurityContentObject): @model_validator(mode="before") @classmethod def validate_fields_from_json(cls, data:Any)->Any: - raw_json = data.get("json_obj", None) + yml_file_name:str|None = data.get("file_path", None) + if yml_file_name is None: + raise ValueError("File name not passed to dashboard constructor") + yml_file_path = pathlib.Path(yml_file_name) + json_file_path = yml_file_path.with_suffix(".json") + + if not json_file_path.is_file(): + raise ValueError(f"Required file {json_file_path} does not exist.") - try: - json_obj = json.loads(raw_json) - except Exception as e: - raise ValueError(f"Error getting field 'json_obj'. Field does not contain valid JSON: {str(e)}") + with open(json_file_path,'r') as jsonFilePointer: + try: + json_obj:dict[str,Any] = json.load(jsonFilePointer) + except Exception as e: + raise ValueError(f"Unable to load data from {json_file_path}: {str(e)}") name_from_file = data.get("name",None) name_from_json = json_obj.get("title",None) - description_from_file = data.get("description",None) - description_from_json = json_obj.get("description",None) errors:list[str] = [] if name_from_json is None: - errors.append("'title' field is missing from field 'json_object'") - elif name_from_json is not None and name_from_file is None: - name_from_file = name_from_json + errors.append(f"'title' field is missing from {json_file_path}") elif name_from_json != name_from_file: errors.append(f"title 'json_object' is '{name_from_json}', but the name defined in the YML is '{name_from_file}'. These two must match.") + + + if data.get("description",None) is not None: + raise ValueError(f"The description field should not be defined in {yml_file_path}, is is read from {json_file_path}") + + description_from_json = json_obj.get("description",None) if description_from_json is None: errors.append("'description' field is missing from field 'json_object'") - elif description_from_json is not None and description_from_file is None: - description_from_file = description_from_file - elif description_from_json != description_from_file: - errors.append(f"description in 'json_object' is '{description_from_json}', but the description defined in the YML is '{description_from_file}'. These two must match.") - data['name'] = name_from_json - data['description'] = description_from_json if len(errors) > 0 : err_string = "\n".join(errors) raise ValueError(f"Error(s) validating dashboard:\n{err_string}") + + data['name'] = name_from_file + data['description'] = description_from_json + data['json_obj'] = json.dumps(json_obj) return data From 34aa0b12df44c0cc611e66a4549b9b37f3e28417 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Tue, 6 Aug 2024 15:09:31 -0700 Subject: [PATCH 31/46] make some tweaks to how the label is generated. In addition, allow a different description in the file than the one that is in the JSON. --- contentctl/objects/dashboard.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/contentctl/objects/dashboard.py b/contentctl/objects/dashboard.py index db221806..44fd387c 100644 --- a/contentctl/objects/dashboard.py +++ b/contentctl/objects/dashboard.py @@ -9,7 +9,7 @@ from contentctl.objects.config import build DEFAULT_DASHBAORD_JINJA2_TEMPLATE = ''' - + str: + return f"{config.app.label} - {self.name}" @model_validator(mode="before") @classmethod @@ -55,23 +60,19 @@ def validate_fields_from_json(cls, data:Any)->Any: if name_from_json is None: errors.append(f"'title' field is missing from {json_file_path}") elif name_from_json != name_from_file: - errors.append(f"title 'json_object' is '{name_from_json}', but the name defined in the YML is '{name_from_file}'. These two must match.") - - - if data.get("description",None) is not None: - raise ValueError(f"The description field should not be defined in {yml_file_path}, is is read from {json_file_path}") + errors.append(f"The 'title' field in the JSON file [{json_file_path}] does not match the 'name' field in the YML object [{yml_file_path}]. These two MUST match:\n " + f"title in JSON : {name_from_json}\n " + f"title in YML : {name_from_file}\n ") description_from_json = json_obj.get("description",None) - if description_from_json is None: errors.append("'description' field is missing from field 'json_object'") if len(errors) > 0 : - err_string = "\n".join(errors) - raise ValueError(f"Error(s) validating dashboard:\n{err_string}") + err_string = "\n - ".join(errors) + raise ValueError(f"Error(s) validating dashboard:\n - {err_string}") data['name'] = name_from_file - data['description'] = description_from_json data['json_obj'] = json.dumps(json_obj) return data From d410bc40457971c7a12ab19c77ab7f9e710eb988 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Fri, 9 Aug 2024 07:02:07 -0700 Subject: [PATCH 32/46] More cleanup of SecurityContentObject_Abstract fields that are required/prepopulated with certain values. Where some Objects like Macros, Lookups, and Deployments are missing values today, defaults are declared in those objects instead of in SecurityContentObject_Abstract itself. --- .../security_content_object_abstract.py | 20 ++++++------------- contentctl/objects/deployment.py | 16 ++++++++++++--- contentctl/objects/lookup.py | 9 ++++++++- contentctl/objects/macro.py | 10 ++++++++-- 4 files changed, 35 insertions(+), 20 deletions(-) diff --git a/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py b/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py index 9df83ccb..f946e50a 100644 --- a/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py @@ -26,21 +26,13 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC): model_config = ConfigDict(use_enum_values=True,validate_default=True) - #name:str = Field(...,max_length=50) - # author: str = Field(...,max_length=255) - # date: datetime.date = Field(...) - # version: NonNegativeInt = ... - # id: uuid.UUID = Field(default_factory=uuid.uuid4) #we set a default here until all content has a uuid - # description: str = Field(...,max_length=1000) - # file_path: FilePath = Field(...) - # references: Optional[List[HttpUrl]] = None - name: str = Field("NO_NAME") - author: str = Field("Content Author",max_length=255) - date: datetime.date = Field(datetime.date.today()) - version: NonNegativeInt = 1 - id: uuid.UUID = Field(default_factory=uuid.uuid4) #we set a default here until all content has a uuid - description: str = Field("Enter Description Here",max_length=10000) + name: str = Field(...,max_length=99) + author: str = Field(...,max_length=255) + date: datetime.date = Field(...) + version: NonNegativeInt = Field(...) + id: uuid.UUID = Field(...) #we set a default here until all content has a uuid + description: str = Field(...,max_length=10000) file_path: Optional[FilePath] = None references: Optional[List[HttpUrl]] = None diff --git a/contentctl/objects/deployment.py b/contentctl/objects/deployment.py index f2b2f391..6808b267 100644 --- a/contentctl/objects/deployment.py +++ b/contentctl/objects/deployment.py @@ -1,7 +1,8 @@ from __future__ import annotations -from pydantic import Field, computed_field, model_validator,ValidationInfo, model_serializer -from typing import Optional,Any - +from pydantic import Field, computed_field,ValidationInfo, model_serializer, NonNegativeInt +from typing import Any +import uuid +import datetime from contentctl.objects.security_content_object import SecurityContentObject from contentctl.objects.deployment_scheduling import DeploymentScheduling from contentctl.objects.alert_action import AlertAction @@ -15,9 +16,13 @@ class Deployment(SecurityContentObject): #author: str = None #description: str = None #contentType: SecurityContentType = SecurityContentType.deployments + + scheduling: DeploymentScheduling = Field(...) alert_action: AlertAction = AlertAction() type: DeploymentType = Field(...) + author: str = Field("NO AUTHOR DEFINED",max_length=255) + version: NonNegativeInt = 1 #Type was the only tag exposed and should likely be removed/refactored. #For transitional reasons, provide this as a computed_field in prep for removal @@ -38,6 +43,11 @@ def getDeployment(v:dict[str,Any], info:ValidationInfo)->Deployment: raise ValueError("Could not create inline deployment - Baseline or Detection lacking 'name' field,") v['name'] = f"{detection_name} - Inline Deployment" + #inline deployment also gets its own, ephemeral id + v['id'] = uuid.uuid4() + v['date'] = datetime.date.today() + v['description'] = "Inline deployment created at runtime" + # This constructs a temporary in-memory deployment, # allowing the deployment to be easily defined in the # detection on a per detection basis. diff --git a/contentctl/objects/lookup.py b/contentctl/objects/lookup.py index d0b88fc8..219f2509 100644 --- a/contentctl/objects/lookup.py +++ b/contentctl/objects/lookup.py @@ -1,8 +1,10 @@ from __future__ import annotations -from pydantic import field_validator, ValidationInfo, model_validator, FilePath, model_serializer +from pydantic import field_validator, ValidationInfo, model_validator, FilePath, model_serializer, Field, NonNegativeInt from typing import TYPE_CHECKING, Optional, Any, Union import re import csv +import uuid +import datetime if TYPE_CHECKING: from contentctl.input.director import DirectorOutputDto from contentctl.objects.config import validate @@ -30,6 +32,11 @@ class Lookup(SecurityContentObject): match_type: Optional[str] = None min_matches: Optional[int] = None case_sensitive_match: Optional[bool] = None + # TODO: Add id field to all lookup ymls + id: uuid.UUID = Field(default_factory=uuid.uuid4) + date: datetime.date = Field(datetime.date.today()) + author: str = Field("NO AUTHOR DEFINED",max_length=255) + version: NonNegativeInt = 1 @model_serializer diff --git a/contentctl/objects/macro.py b/contentctl/objects/macro.py index 5f01f2d1..48daf602 100644 --- a/contentctl/objects/macro.py +++ b/contentctl/objects/macro.py @@ -3,7 +3,9 @@ from __future__ import annotations from typing import TYPE_CHECKING, List import re -from pydantic import Field, model_serializer +from pydantic import Field, model_serializer, NonNegativeInt +import uuid +import datetime if TYPE_CHECKING: from contentctl.input.director import DirectorOutputDto from contentctl.objects.security_content_object import SecurityContentObject @@ -22,7 +24,11 @@ class Macro(SecurityContentObject): definition: str = Field(..., min_length=1) arguments: List[str] = Field([]) - + # TODO: Add id field to all macro ymls + id: uuid.UUID = Field(default_factory=uuid.uuid4) + date: datetime.date = Field(datetime.date.today()) + author: str = Field("NO AUTHOR DEFINED",max_length=255) + version: NonNegativeInt = 1 From c69010bc70ad50684cb8481520fabb59c38acbe9 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Mon, 19 Aug 2024 16:29:19 -0700 Subject: [PATCH 33/46] same tweak to default fields in deployment --- contentctl/objects/deployment.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/contentctl/objects/deployment.py b/contentctl/objects/deployment.py index 6808b267..832c048d 100644 --- a/contentctl/objects/deployment.py +++ b/contentctl/objects/deployment.py @@ -21,7 +21,7 @@ class Deployment(SecurityContentObject): scheduling: DeploymentScheduling = Field(...) alert_action: AlertAction = AlertAction() type: DeploymentType = Field(...) - author: str = Field("NO AUTHOR DEFINED",max_length=255) + author: str = Field(...,max_length=255) version: NonNegativeInt = 1 #Type was the only tag exposed and should likely be removed/refactored. @@ -30,7 +30,8 @@ class Deployment(SecurityContentObject): @property def tags(self)->dict[str,DeploymentType]: return {"type": self.type} - + + @staticmethod def getDeployment(v:dict[str,Any], info:ValidationInfo)->Deployment: if v != {}: @@ -41,12 +42,16 @@ def getDeployment(v:dict[str,Any], info:ValidationInfo)->Deployment: detection_name = info.data.get("name", None) if detection_name is None: raise ValueError("Could not create inline deployment - Baseline or Detection lacking 'name' field,") - - v['name'] = f"{detection_name} - Inline Deployment" - #inline deployment also gets its own, ephemeral id - v['id'] = uuid.uuid4() - v['date'] = datetime.date.today() - v['description'] = "Inline deployment created at runtime" + + # Add a number of static values + v.update({ + 'name': f"{detection_name} - Inline Deployment", + 'id':uuid.uuid4(), + 'date': datetime.date.today(), + 'description': "Inline deployment created at runtime.", + 'author': "contentctl tool" + }) + # This constructs a temporary in-memory deployment, # allowing the deployment to be easily defined in the From 5c0e28ab9a09aeb87ac382cfd745065882415ed9 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Wed, 21 Aug 2024 16:36:04 -0700 Subject: [PATCH 34/46] Make the fix for baselines and reponse tasks (investigations) as well. --- contentctl/objects/baseline.py | 10 ++++++++++ contentctl/objects/investigation.py | 12 ++++++++++-- .../output/templates/savedsearches_baselines.j2 | 3 +-- .../output/templates/savedsearches_detections.j2 | 1 - .../output/templates/savedsearches_investigations.j2 | 3 +-- 5 files changed, 22 insertions(+), 7 deletions(-) diff --git a/contentctl/objects/baseline.py b/contentctl/objects/baseline.py index 75c95c5b..be8e1ba3 100644 --- a/contentctl/objects/baseline.py +++ b/contentctl/objects/baseline.py @@ -6,6 +6,7 @@ from contentctl.objects.security_content_object import SecurityContentObject from contentctl.objects.enums import DataModel from contentctl.objects.baseline_tags import BaselineTags +from contentctl.objects.config import CustomApp #from contentctl.objects.deployment import Deployment # from typing import TYPE_CHECKING @@ -24,6 +25,15 @@ class Baseline(SecurityContentObject): # enrichment deployment: Deployment = Field({}) + + def get_conf_stanza_name(self, app:CustomApp, max_stanza_length:int=81)->str: + stanza_name = f"{app.label} - {self.name}" + if len(stanza_name) > max_stanza_length: + raise ValueError(f"conf stanza may only be {max_stanza_length} characters, " + f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' ") + #print(f"Stanza Length[{len(stanza_name)}]") + return stanza_name + @field_validator("deployment", mode="before") def getDeployment(cls, v:Any, info:ValidationInfo)->Deployment: diff --git a/contentctl/objects/investigation.py b/contentctl/objects/investigation.py index 1b7aec84..56b43d29 100644 --- a/contentctl/objects/investigation.py +++ b/contentctl/objects/investigation.py @@ -5,13 +5,13 @@ from contentctl.objects.security_content_object import SecurityContentObject from contentctl.objects.enums import DataModel from contentctl.objects.investigation_tags import InvestigationTags - +from contentctl.objects.config import CustomApp class Investigation(SecurityContentObject): model_config = ConfigDict(use_enum_values=True,validate_default=False) type: str = Field(...,pattern="^Investigation$") datamodel: list[DataModel] = Field(...) - + name:str = Field(...,max_length=67) search: str = Field(...) how_to_implement: str = Field(...) known_false_positives: str = Field(...) @@ -68,5 +68,13 @@ def model_post_init(self, ctx:dict[str,Any]): for story in self.tags.analytic_story: story.investigations.append(self) + def get_conf_stanza_name(self, app:CustomApp, max_stanza_length:int=81)->str: + stanza_name = f"{app.label} - {self.name} - Response Task" + if len(stanza_name) > max_stanza_length: + raise ValueError(f"conf stanza may only be {max_stanza_length} characters, " + f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' ") + #print(f"Stanza Length[{len(stanza_name)}]") + return stanza_name + \ No newline at end of file diff --git a/contentctl/output/templates/savedsearches_baselines.j2 b/contentctl/output/templates/savedsearches_baselines.j2 index a4ae29f1..fffe116c 100644 --- a/contentctl/output/templates/savedsearches_baselines.j2 +++ b/contentctl/output/templates/savedsearches_baselines.j2 @@ -4,11 +4,10 @@ {% for detection in objects %} {% if (detection.type == 'Baseline') %} -[{{app.label}} - {{ detection.name }}] +[{{ detection.get_conf_stanza_name(app) }}] action.escu = 0 action.escu.enabled = 1 action.escu.search_type = support -action.escu.full_search_name = {{app.label}} - {{ detection.name }} description = {{ detection.description | escapeNewlines() }} action.escu.creation_date = {{ detection.date }} action.escu.modification_date = {{ detection.date }} diff --git a/contentctl/output/templates/savedsearches_detections.j2 b/contentctl/output/templates/savedsearches_detections.j2 index 4002fad3..e28e984e 100644 --- a/contentctl/output/templates/savedsearches_detections.j2 +++ b/contentctl/output/templates/savedsearches_detections.j2 @@ -28,7 +28,6 @@ action.escu.known_false_positives = None action.escu.creation_date = {{ detection.date }} action.escu.modification_date = {{ detection.date }} action.escu.confidence = high -action.escu.full_search_name = {{app.label}} - {{ detection.name }} - Rule action.escu.search_type = detection {% if detection.tags.product is defined %} action.escu.product = {{ detection.tags.product | tojson }} diff --git a/contentctl/output/templates/savedsearches_investigations.j2 b/contentctl/output/templates/savedsearches_investigations.j2 index 8c2038ea..6615af2d 100644 --- a/contentctl/output/templates/savedsearches_investigations.j2 +++ b/contentctl/output/templates/savedsearches_investigations.j2 @@ -5,11 +5,10 @@ {% for detection in objects %} {% if (detection.type == 'Investigation') %} {% if detection.search is defined %} -[{{app.label}} - {{ detection.name }} - Response Task] +[{{ detection.get_conf_stanza_name(app) }}] action.escu = 0 action.escu.enabled = 1 action.escu.search_type = investigative -action.escu.full_search_name = {{app.label}} - {{ detection.name }} - Response Task description = {{ detection.description | escapeNewlines() }} action.escu.creation_date = {{ detection.date }} action.escu.modification_date = {{ detection.date }} From 5adc1b3f5b249c83f422e94c76e0d73811d2d5e3 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Wed, 21 Aug 2024 16:40:25 -0700 Subject: [PATCH 35/46] Make sure that analyticstories_detections and analyticstories_investigations use the new proper function to get the name of the stanza --- contentctl/output/templates/analyticstories_detections.j2 | 2 +- contentctl/output/templates/analyticstories_investigations.j2 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contentctl/output/templates/analyticstories_detections.j2 b/contentctl/output/templates/analyticstories_detections.j2 index b119632a..ffca83a0 100644 --- a/contentctl/output/templates/analyticstories_detections.j2 +++ b/contentctl/output/templates/analyticstories_detections.j2 @@ -3,7 +3,7 @@ {% for detection in objects %} {% if (detection.type == 'TTP' or detection.type == 'Anomaly' or detection.type == 'Hunting' or detection.type == 'Correlation') %} -[savedsearch://{{app.label}} - {{ detection.name }} - Rule] +[savedsearch://{{ detections.get_conf_stanza_name(app) }}] type = detection asset_type = {{ detection.tags.asset_type.value }} confidence = medium diff --git a/contentctl/output/templates/analyticstories_investigations.j2 b/contentctl/output/templates/analyticstories_investigations.j2 index a7cfa37c..3fba21d0 100644 --- a/contentctl/output/templates/analyticstories_investigations.j2 +++ b/contentctl/output/templates/analyticstories_investigations.j2 @@ -3,7 +3,7 @@ {% for detection in objects %} {% if (detection.type == 'Investigation') %} -[savedsearch://{{app.label}} - {{ detection.name }} - Response Task] +[savedsearch://{{ detections.get_conf_stanza_name(app) }}] type = investigation explanation = none {% if detection.how_to_implement is defined %} From 67d9cddf5b17f613a9f6fc23364c95fb11a1692c Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Wed, 21 Aug 2024 16:42:47 -0700 Subject: [PATCH 36/46] Fix variable name which was incorrect. --- contentctl/output/templates/analyticstories_detections.j2 | 2 +- contentctl/output/templates/analyticstories_investigations.j2 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/contentctl/output/templates/analyticstories_detections.j2 b/contentctl/output/templates/analyticstories_detections.j2 index ffca83a0..2f9a1318 100644 --- a/contentctl/output/templates/analyticstories_detections.j2 +++ b/contentctl/output/templates/analyticstories_detections.j2 @@ -3,7 +3,7 @@ {% for detection in objects %} {% if (detection.type == 'TTP' or detection.type == 'Anomaly' or detection.type == 'Hunting' or detection.type == 'Correlation') %} -[savedsearch://{{ detections.get_conf_stanza_name(app) }}] +[savedsearch://{{ detection.get_conf_stanza_name(app) }}] type = detection asset_type = {{ detection.tags.asset_type.value }} confidence = medium diff --git a/contentctl/output/templates/analyticstories_investigations.j2 b/contentctl/output/templates/analyticstories_investigations.j2 index 3fba21d0..7362d005 100644 --- a/contentctl/output/templates/analyticstories_investigations.j2 +++ b/contentctl/output/templates/analyticstories_investigations.j2 @@ -3,7 +3,7 @@ {% for detection in objects %} {% if (detection.type == 'Investigation') %} -[savedsearch://{{ detections.get_conf_stanza_name(app) }}] +[savedsearch://{{ detection.get_conf_stanza_name(app) }}] type = investigation explanation = none {% if detection.how_to_implement is defined %} From 307d8f1b34557ae4eccf6161df6a48261237d5f1 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Fri, 30 Aug 2024 12:43:43 -0700 Subject: [PATCH 37/46] removed dupliocate risk functionality in detection_Tags --- contentctl/objects/detection_tags.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/contentctl/objects/detection_tags.py b/contentctl/objects/detection_tags.py index c9de6ff0..3de1019a 100644 --- a/contentctl/objects/detection_tags.py +++ b/contentctl/objects/detection_tags.py @@ -77,17 +77,6 @@ def severity(self)->RiskSeverity: required_fields: list[str] = Field(min_length=1) throttling: Optional[Throttling] = None security_domain: SecurityDomain = Field(...) - - @computed_field - @property - def risk_severity(self) -> RiskSeverity: - if self.risk_score >= 80: - return RiskSeverity('high') - elif (self.risk_score >= 50 and self.risk_score <= 79): - return RiskSeverity('medium') - else: - return RiskSeverity('low') - cve: List[CVE_TYPE] = [] atomic_guid: List[AtomicTest] = [] drilldown_search: Optional[str] = None From 02e64df1b5f3e418f19af0c53fdc56fc5787afd7 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Wed, 18 Sep 2024 16:03:45 -0700 Subject: [PATCH 38/46] cleanup capitalization --- contentctl/objects/detection_tags.py | 1 - contentctl/objects/enums.py | 20 ++++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/contentctl/objects/detection_tags.py b/contentctl/objects/detection_tags.py index d57b38f0..71925a22 100644 --- a/contentctl/objects/detection_tags.py +++ b/contentctl/objects/detection_tags.py @@ -23,7 +23,6 @@ from contentctl.objects.mitre_attack_enrichment import MitreAttackEnrichment from contentctl.objects.constants import ATTACK_TACTICS_KILLCHAIN_MAPPING from contentctl.objects.observable import Observable -from contentctl.objects.enums import Cis18Value, AssetType, SecurityDomain, RiskSeverity, KillChainPhase, NistCategory, SecurityContentProductName from contentctl.objects.enums import ( Cis18Value, AssetType, diff --git a/contentctl/objects/enums.py b/contentctl/objects/enums.py index 6d4cb12e..74d3ee7d 100644 --- a/contentctl/objects/enums.py +++ b/contentctl/objects/enums.py @@ -410,13 +410,13 @@ class NistCategory(str, enum.Enum): class RiskSeverity(str,enum.Enum): # Levels taken from the following documentation link # https://docs.splunk.com/Documentation/ES/7.3.2/User/RiskScoring - # 20 - Info (0-20 for us) - # 40 - Low (21-40 for us) - # 60 - Medium (41-60 for us) - # 80 - High (61-80 for us) - # 100 - Critical (81 - 100 for us) - INFO = "Info" - LOW = "Low" - MEDIUM = "Medium" - HIGH = "High" - CRITICAL = "Critical" + # 20 - info (0-20 for us) + # 40 - low (21-40 for us) + # 60 - medium (41-60 for us) + # 80 - high (61-80 for us) + # 100 - critical (81 - 100 for us) + INFO = "info" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + CRITICAL = "critical" From 35ad3c5b992ecd0f98f207ad97389a1a81c56725 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Wed, 18 Sep 2024 16:13:39 -0700 Subject: [PATCH 39/46] Fix minor print typo --- .../infrastructures/DetectionTestingInfrastructure.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py b/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py index 558c2818..95ebc464 100644 --- a/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +++ b/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py @@ -287,7 +287,7 @@ def configure_imported_roles( return except Exception as e: self.pbar.write( - f"The follwoing roles do not exist:'{enterprise_security_roles}: {str(e)}" + f"The following role(s) do not exist:'{enterprise_security_roles}: {str(e)}" ) self.get_conn().roles.post( From 8e27224eb45028719bec9e7b36afe158644351d6 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Thu, 19 Sep 2024 10:11:08 -0700 Subject: [PATCH 40/46] remove finding report, which is an ssa thing, from the repo --- contentctl/output/templates/finding_report.j2 | 30 ------------------- 1 file changed, 30 deletions(-) delete mode 100644 contentctl/output/templates/finding_report.j2 diff --git a/contentctl/output/templates/finding_report.j2 b/contentctl/output/templates/finding_report.j2 deleted file mode 100644 index 33f10db0..00000000 --- a/contentctl/output/templates/finding_report.j2 +++ /dev/null @@ -1,30 +0,0 @@ - - | eval devices = [{"hostname": device_hostname, "type_id": 0, "uuid": device.uuid, "ip": device.ip, "mac": device.mac}], - time = timestamp, - evidence = {{ detection.tags.evidence_str }}, - message = "{{ detection.name }} has been triggered on " + device_hostname + " by " + {{ actor_user_name }} + ".", - users = [{"name": {{ actor_user_name }}, "uuid": actor_user.uuid, "uid": actor_user.uid, "account_uid": actor_user.account_uid, "email_addr": actor_user.email_addr}], - activity_id = 1, - cis_csc = [{"control": "CIS 10", "version": 8}], - analytic_stories = {{ detection.tags.analytics_story_str }}, - class_name = "Detection Report", - confidence = {{ detection.tags.confidence }}, - confidence_id = {{ detection.tags.confidence_id }}, - duration = 0, - impact = {{ detection.tags.impact }}, - impact_id = {{ detection.tags.impact_id }}, - kill_chain = {{ detection.tags.kill_chain_phases_str }}, - nist = ["DE.AE"], - risk_level = "{{ detection.tags.risk_level }}", - category_uid = 2, - class_uid = 102001, - risk_level_id = {{ detection.tags.risk_level_id }}, - risk_score = {{ detection.tags.risk_score }}, - severity_id = 0, - rule = {"name": "{{ detection.name }}", "uid": "{{ detection.id }}", "type": "Streaming"}, - metadata = {"customer_uid": metadata.customer_uid, "product": {"name": "Behavior Analytics", "vendor_name": "Splunk"}, "version": "1.0.0-rc.2", "logged_time": time()}, - type_uid = 10200101, - start_time = timestamp, - end_time = timestamp - | fields metadata, rule, activity_id, analytic_stories, cis_csc, category_uid, class_name, class_uid, confidence, confidence_id, devices, duration, time, evidence, impact, impact_id, kill_chain, message, nist, observables, risk_level, risk_level_id, risk_score, severity_id, type_uid, users, start_time, end_time - | into sink; From d66a9f69d93f0f69fb6c36fd867e7ffe537d7546 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Thu, 19 Sep 2024 16:03:03 -0700 Subject: [PATCH 41/46] Updates in response to PR review with colleague. --- .../detection_abstract.py | 41 ++++++++----------- .../security_content_object_abstract.py | 11 ++++- contentctl/objects/baseline.py | 17 +------- contentctl/objects/constants.py | 27 ++++++++++++ contentctl/objects/dashboard.py | 10 +++-- contentctl/objects/investigation.py | 13 ++---- contentctl/output/conf_writer.py | 5 +++ 7 files changed, 72 insertions(+), 52 deletions(-) diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index cfbf8c85..e3b82253 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -40,6 +40,12 @@ from contentctl.objects.enums import ProvidingTechnology from contentctl.enrichments.cve_enrichment import CveEnrichmentObj import datetime +from contentctl.objects.constants import ( + ES_MAX_STANZA_LENGTH, + ES_SEARCH_STANZA_NAME_FORMAT_AFTER_CLONING_IN_PRODUCT_TEMPLATE, + CONTENTCTL_MAX_SEARCH_NAME_LENGTH +) + MISSING_SOURCES: set[str] = set() # Those AnalyticsTypes that we do not test via contentctl @@ -51,7 +57,7 @@ # TODO (#266): disable the use_enum_values configuration class Detection_Abstract(SecurityContentObject): model_config = ConfigDict(use_enum_values=True) - name:str = Field(...,max_length=67) + name:str = Field(...,max_length=CONTENTCTL_MAX_SEARCH_NAME_LENGTH) #contentType: SecurityContentType = SecurityContentType.detections type: AnalyticsType = Field(...) status: DetectionStatus = Field(...) @@ -74,26 +80,19 @@ class Detection_Abstract(SecurityContentObject): data_source_objects: list[DataSource] = [] - def get_action_dot_correlationsearch_dot_label(self, app:CustomApp, max_stanza_length:int=99)->str: - label = self.get_conf_stanza_name(app) - label_after_saving_in_product = f"{self.tags.security_domain.value} - {label} - Rule" - - if len(label_after_saving_in_product) > max_stanza_length: - raise ValueError(f"label may only be {max_stanza_length} characters to allow updating in-product, " - f"but stanza was actually {len(label_after_saving_in_product)} characters: '{label_after_saving_in_product}' ") + def get_action_dot_correlationsearch_dot_label(self, app:CustomApp, max_stanza_length:int=ES_MAX_STANZA_LENGTH)->str: + stanza_name = self.get_conf_stanza_name(app) + stanza_name_after_saving_in_es = ES_SEARCH_STANZA_NAME_FORMAT_AFTER_CLONING_IN_PRODUCT_TEMPLATE.format( + security_domain_value = self.tags.security_domain.value, + search_name = stanza_name + ) - return label - - def get_conf_stanza_name(self, app:CustomApp, max_stanza_length:int=81)->str: - stanza_name = f"{app.label} - {self.name} - Rule" - if len(stanza_name) > max_stanza_length: - raise ValueError(f"conf stanza may only be {max_stanza_length} characters, " - f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' ") - #print(f"Stanza Length[{len(stanza_name)}]") - return stanza_name + if len(stanza_name_after_saving_in_es) > max_stanza_length: + raise ValueError(f"label may only be {max_stanza_length} characters to allow updating in-product, " + f"but stanza was actually {len(stanza_name_after_saving_in_es)} characters: '{stanza_name_after_saving_in_es}' ") - + return stanza_name @field_validator("search", mode="before") @classmethod @@ -674,7 +673,7 @@ def addTags_nist(self): else: self.tags.nist = [NistCategory.DE_AE] return self - + @model_validator(mode="after") def ensureThrottlingFieldsExist(self): @@ -685,10 +684,6 @@ def ensureThrottlingFieldsExist(self): if self.tags.throttling is None: # No throttling configured for this detection return self - - if not isinstance(self.search, str): - # Search is sigma-formatted, so we cannot perform this validation. - return self missing_fields:list[str] = [field for field in self.tags.throttling.fields if field not in self.search] if len(missing_fields) > 0: diff --git a/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py b/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py index 28454979..779707b9 100644 --- a/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py @@ -5,8 +5,10 @@ from contentctl.objects.deployment import Deployment from contentctl.objects.security_content_object import SecurityContentObject from contentctl.input.director import DirectorOutputDto + from contentctl.objects.config import CustomApp from contentctl.objects.enums import AnalyticsType +from contentctl.objects.constants import CONTENTCTL_MAX_STANZA_LENGTH, CONTENTCTL_STANZA_NAME_FORMAT_TEMPLATE import abc import uuid import datetime @@ -56,7 +58,14 @@ def serialize_model(self): "description": self.description, "references": [str(url) for url in self.references or []] } - + + def get_conf_stanza_name(self, app:CustomApp, max_stanza_length:int=CONTENTCTL_MAX_STANZA_LENGTH)->str: + stanza_name = CONTENTCTL_STANZA_NAME_FORMAT_TEMPLATE.format(app_label=app.label, detection_name=self.name) + if len(stanza_name) > max_stanza_length: + raise ValueError(f"conf stanza may only be {max_stanza_length} characters, " + f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' ") + return stanza_name + @staticmethod def objectListToNameList(objects: list[SecurityContentObject]) -> list[str]: return [object.getName() for object in objects] diff --git a/contentctl/objects/baseline.py b/contentctl/objects/baseline.py index be8e1ba3..ebb367cc 100644 --- a/contentctl/objects/baseline.py +++ b/contentctl/objects/baseline.py @@ -6,16 +6,11 @@ from contentctl.objects.security_content_object import SecurityContentObject from contentctl.objects.enums import DataModel from contentctl.objects.baseline_tags import BaselineTags -from contentctl.objects.config import CustomApp -#from contentctl.objects.deployment import Deployment - -# from typing import TYPE_CHECKING -# if TYPE_CHECKING: -# from contentctl.input.director import DirectorOutputDto +from contentctl.objects.constants import CONTENTCTL_MAX_SEARCH_NAME_LENGTH class Baseline(SecurityContentObject): - name:str = Field(...,max_length=67) + name:str = Field(...,max_length=CONTENTCTL_MAX_SEARCH_NAME_LENGTH) type: Annotated[str,Field(pattern="^Baseline$")] = Field(...) datamodel: Optional[List[DataModel]] = None search: str = Field(..., min_length=4) @@ -26,14 +21,6 @@ class Baseline(SecurityContentObject): # enrichment deployment: Deployment = Field({}) - def get_conf_stanza_name(self, app:CustomApp, max_stanza_length:int=81)->str: - stanza_name = f"{app.label} - {self.name}" - if len(stanza_name) > max_stanza_length: - raise ValueError(f"conf stanza may only be {max_stanza_length} characters, " - f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' ") - #print(f"Stanza Length[{len(stanza_name)}]") - return stanza_name - @field_validator("deployment", mode="before") def getDeployment(cls, v:Any, info:ValidationInfo)->Deployment: diff --git a/contentctl/objects/constants.py b/contentctl/objects/constants.py index a65e317c..4c56ee79 100644 --- a/contentctl/objects/constants.py +++ b/contentctl/objects/constants.py @@ -1,3 +1,5 @@ +# Use for calculation of maximum length of name field +from contentctl.objects.enums import SecurityDomain ATTACK_TACTICS_KILLCHAIN_MAPPING = { "Reconnaissance": "Reconnaissance", @@ -140,3 +142,28 @@ # The relative path to the directory where any apps/packages will be downloaded DOWNLOADS_DIRECTORY = "downloads" + +# Maximum length of the name field for a search. +# This number is derived from a limitation that exists in +# ESCU where a search cannot be edited, due to validation +# errors, if its name is longer than 99 characters. +# When an saved search is cloned in Enterprise Security User Interface, +# it is wrapped in the following: +# {Detection.tags.security_domain.value} - {SEARCH_STANZA_NAME} - Rule +# Similarly, when we generate the search stanza name in contentctl, it +# is app.label - detection.name - Rule +# However, in product the search name is: +# {CustomApp.label} - {detection.name} - Rule, +# or in ESCU: +# ESCU - {detection.name} - Rule, +# this gives us a maximum length below. +# When an ESCU search is cloned, it will +# have a full name like (the following is NOT a typo): +# Endpoint - ESCU - Name of Search From YML File - Rule - Rule +# The math below accounts for all these caveats +ES_MAX_STANZA_LENGTH = 99 +CONTENTCTL_STANZA_NAME_FORMAT_TEMPLATE = "{app_label} - {detection_name} - Rule" +ES_SEARCH_STANZA_NAME_FORMAT_AFTER_CLONING_IN_PRODUCT_TEMPLATE = "{security_domain_value} - {search_name} - Rule" +SECURITY_DOMAIN_MAX_LENGTH = max([len(SecurityDomain[value]) for value in SecurityDomain._member_map_]) +CONTENTCTL_MAX_STANZA_LENGTH = ES_MAX_STANZA_LENGTH - len(ES_SEARCH_STANZA_NAME_FORMAT_AFTER_CLONING_IN_PRODUCT_TEMPLATE.format(security_domain_value="X"*SECURITY_DOMAIN_MAX_LENGTH,search_name="")) +CONTENTCTL_MAX_SEARCH_NAME_LENGTH = CONTENTCTL_MAX_STANZA_LENGTH - len(CONTENTCTL_STANZA_NAME_FORMAT_TEMPLATE.format(app_label="ESCU", detection_name="")) \ No newline at end of file diff --git a/contentctl/objects/dashboard.py b/contentctl/objects/dashboard.py index 44fd387c..a8d1f9b0 100644 --- a/contentctl/objects/dashboard.py +++ b/contentctl/objects/dashboard.py @@ -2,13 +2,13 @@ from pydantic import Field, Json, model_validator import pathlib -import copy from jinja2 import Environment import json from contentctl.objects.security_content_object import SecurityContentObject from contentctl.objects.config import build +from enum import StrEnum -DEFAULT_DASHBAORD_JINJA2_TEMPLATE = ''' +DEFAULT_DASHBAORD_JINJA2_TEMPLATE = ''' ''' +class DashboardTheme(StrEnum): + light = "light" + dark = "dark" + class Dashboard(SecurityContentObject): j2_template: str = Field(default=DEFAULT_DASHBAORD_JINJA2_TEMPLATE, description="Jinja2 Template used to construct the dashboard") description: str = Field(...,description="A description of the dashboard. This does not have to match " "the description of the dashboard in the JSON file.", max_length=10000) - + theme: DashboardTheme = Field(default=DashboardTheme.dark, description="The theme of the dashboard. Choose between 'light' and 'dark'.") json_obj: Json[dict[str,Any]] = Field(..., description="Valid JSON object that describes the dashboard") diff --git a/contentctl/objects/investigation.py b/contentctl/objects/investigation.py index 0e6072a4..6437b395 100644 --- a/contentctl/objects/investigation.py +++ b/contentctl/objects/investigation.py @@ -5,14 +5,15 @@ from contentctl.objects.security_content_object import SecurityContentObject from contentctl.objects.enums import DataModel from contentctl.objects.investigation_tags import InvestigationTags -from contentctl.objects.config import CustomApp + +from contentctl.objects.constants import CONTENTCTL_MAX_SEARCH_NAME_LENGTH # TODO (#266): disable the use_enum_values configuration class Investigation(SecurityContentObject): model_config = ConfigDict(use_enum_values=True,validate_default=False) type: str = Field(...,pattern="^Investigation$") datamodel: list[DataModel] = Field(...) - name:str = Field(...,max_length=67) + name:str = Field(...,max_length=CONTENTCTL_MAX_SEARCH_NAME_LENGTH) search: str = Field(...) how_to_implement: str = Field(...) known_false_positives: str = Field(...) @@ -69,13 +70,5 @@ def model_post_init(self, ctx:dict[str,Any]): for story in self.tags.analytic_story: story.investigations.append(self) - def get_conf_stanza_name(self, app:CustomApp, max_stanza_length:int=81)->str: - stanza_name = f"{app.label} - {self.name} - Response Task" - if len(stanza_name) > max_stanza_length: - raise ValueError(f"conf stanza may only be {max_stanza_length} characters, " - f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' ") - #print(f"Stanza Length[{len(stanza_name)}]") - return stanza_name - \ No newline at end of file diff --git a/contentctl/output/conf_writer.py b/contentctl/output/conf_writer.py index 65a11196..4d2e0490 100644 --- a/contentctl/output/conf_writer.py +++ b/contentctl/output/conf_writer.py @@ -113,6 +113,11 @@ def writeDashboardFiles(config:build, dashboards:list[Dashboard])->set[pathlib.P written_files:set[pathlib.Path] = set() for dashboard in dashboards: output_file_path = dashboard.getOutputFilepathRelativeToAppRoot(config) + # Check that the full output path does not exist so that we are not having an + # name collision with a file in app_template + if (config.getPackageDirectoryPath()/output_file_path).exists(): + raise FileExistsError(f"ERROR: Overwriting Dashboard File {output_file_path}. Does this file exist in {config.getAppTemplatePath()} AND {config.path/'dashboards'}?") + ConfWriter.writeXmlFileHeader(output_file_path, config) dashboard.writeDashboardFile(ConfWriter.getJ2Environment(), config) ConfWriter.validateXmlFile(config.getPackageDirectoryPath()/output_file_path) From 59ea4f0e38b5869164f2baddb17c0e8c21f5b4d3 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Thu, 19 Sep 2024 17:28:27 -0700 Subject: [PATCH 42/46] change default dashbaord theme to light --- contentctl/objects/dashboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contentctl/objects/dashboard.py b/contentctl/objects/dashboard.py index a8d1f9b0..90bfc93f 100644 --- a/contentctl/objects/dashboard.py +++ b/contentctl/objects/dashboard.py @@ -31,7 +31,7 @@ class Dashboard(SecurityContentObject): j2_template: str = Field(default=DEFAULT_DASHBAORD_JINJA2_TEMPLATE, description="Jinja2 Template used to construct the dashboard") description: str = Field(...,description="A description of the dashboard. This does not have to match " "the description of the dashboard in the JSON file.", max_length=10000) - theme: DashboardTheme = Field(default=DashboardTheme.dark, description="The theme of the dashboard. Choose between 'light' and 'dark'.") + theme: DashboardTheme = Field(default=DashboardTheme.light, description="The theme of the dashboard. Choose between 'light' and 'dark'.") json_obj: Json[dict[str,Any]] = Field(..., description="Valid JSON object that describes the dashboard") From a92a9ed6f34f8d19f9cdac2b49ecb787fecb0742 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Wed, 25 Sep 2024 12:51:52 -0700 Subject: [PATCH 43/46] Updates to get the names of Detections, Baselines, and Investigations/Response Tasks Properly written to the conf files. --- .../detection_abstract.py | 9 +++++++- .../security_content_object_abstract.py | 7 +++---- contentctl/objects/baseline.py | 10 ++++++++- contentctl/objects/constants.py | 7 +++++-- contentctl/objects/investigation.py | 21 ++++++++++++++----- contentctl/objects/story.py | 20 ++++-------------- .../analyticstories_investigations.j2 | 10 ++++----- .../templates/analyticstories_stories.j2 | 2 +- .../templates/savedsearches_investigations.j2 | 2 +- 9 files changed, 52 insertions(+), 36 deletions(-) diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index e3b82253..26014886 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -43,7 +43,8 @@ from contentctl.objects.constants import ( ES_MAX_STANZA_LENGTH, ES_SEARCH_STANZA_NAME_FORMAT_AFTER_CLONING_IN_PRODUCT_TEMPLATE, - CONTENTCTL_MAX_SEARCH_NAME_LENGTH + CONTENTCTL_MAX_SEARCH_NAME_LENGTH, + CONTENTCTL_DETECTION_STANZA_NAME_FORMAT_TEMPLATE ) MISSING_SOURCES: set[str] = set() @@ -80,6 +81,12 @@ class Detection_Abstract(SecurityContentObject): data_source_objects: list[DataSource] = [] + def get_conf_stanza_name(self, app:CustomApp)->str: + stanza_name = CONTENTCTL_DETECTION_STANZA_NAME_FORMAT_TEMPLATE.format(app_label=app.label, detection_name=self.name) + self.check_conf_stanza_max_length(stanza_name) + return stanza_name + + def get_action_dot_correlationsearch_dot_label(self, app:CustomApp, max_stanza_length:int=ES_MAX_STANZA_LENGTH)->str: stanza_name = self.get_conf_stanza_name(app) stanza_name_after_saving_in_es = ES_SEARCH_STANZA_NAME_FORMAT_AFTER_CLONING_IN_PRODUCT_TEMPLATE.format( diff --git a/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py b/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py index 779707b9..f93602f1 100644 --- a/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py @@ -8,7 +8,7 @@ from contentctl.objects.config import CustomApp from contentctl.objects.enums import AnalyticsType -from contentctl.objects.constants import CONTENTCTL_MAX_STANZA_LENGTH, CONTENTCTL_STANZA_NAME_FORMAT_TEMPLATE +from contentctl.objects.constants import CONTENTCTL_MAX_STANZA_LENGTH import abc import uuid import datetime @@ -59,12 +59,11 @@ def serialize_model(self): "references": [str(url) for url in self.references or []] } - def get_conf_stanza_name(self, app:CustomApp, max_stanza_length:int=CONTENTCTL_MAX_STANZA_LENGTH)->str: - stanza_name = CONTENTCTL_STANZA_NAME_FORMAT_TEMPLATE.format(app_label=app.label, detection_name=self.name) + + def check_conf_stanza_max_length(self, stanza_name:str, max_stanza_length:int=CONTENTCTL_MAX_STANZA_LENGTH) -> None: if len(stanza_name) > max_stanza_length: raise ValueError(f"conf stanza may only be {max_stanza_length} characters, " f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' ") - return stanza_name @staticmethod def objectListToNameList(objects: list[SecurityContentObject]) -> list[str]: diff --git a/contentctl/objects/baseline.py b/contentctl/objects/baseline.py index ebb367cc..5dc59d8f 100644 --- a/contentctl/objects/baseline.py +++ b/contentctl/objects/baseline.py @@ -7,7 +7,10 @@ from contentctl.objects.enums import DataModel from contentctl.objects.baseline_tags import BaselineTags -from contentctl.objects.constants import CONTENTCTL_MAX_SEARCH_NAME_LENGTH +from contentctl.objects.config import CustomApp + + +from contentctl.objects.constants import CONTENTCTL_MAX_SEARCH_NAME_LENGTH,CONTENTCTL_BASELINE_STANZA_NAME_FORMAT_TEMPLATE class Baseline(SecurityContentObject): name:str = Field(...,max_length=CONTENTCTL_MAX_SEARCH_NAME_LENGTH) @@ -22,6 +25,11 @@ class Baseline(SecurityContentObject): deployment: Deployment = Field({}) + def get_conf_stanza_name(self, app:CustomApp)->str: + stanza_name = CONTENTCTL_BASELINE_STANZA_NAME_FORMAT_TEMPLATE.format(app_label=app.label, detection_name=self.name) + self.check_conf_stanza_max_length(stanza_name) + return stanza_name + @field_validator("deployment", mode="before") def getDeployment(cls, v:Any, info:ValidationInfo)->Deployment: return Deployment.getDeployment(v,info) diff --git a/contentctl/objects/constants.py b/contentctl/objects/constants.py index 4c56ee79..c295ec86 100644 --- a/contentctl/objects/constants.py +++ b/contentctl/objects/constants.py @@ -162,8 +162,11 @@ # Endpoint - ESCU - Name of Search From YML File - Rule - Rule # The math below accounts for all these caveats ES_MAX_STANZA_LENGTH = 99 -CONTENTCTL_STANZA_NAME_FORMAT_TEMPLATE = "{app_label} - {detection_name} - Rule" +CONTENTCTL_DETECTION_STANZA_NAME_FORMAT_TEMPLATE = "{app_label} - {detection_name} - Rule" +CONTENTCTL_BASELINE_STANZA_NAME_FORMAT_TEMPLATE = "{app_label} - {detection_name}" +CONTENTCTL_RESPONSE_TASK_NAME_FORMAT_TEMPLATE = "{app_label} - {detection_name} - Response Task" + ES_SEARCH_STANZA_NAME_FORMAT_AFTER_CLONING_IN_PRODUCT_TEMPLATE = "{security_domain_value} - {search_name} - Rule" SECURITY_DOMAIN_MAX_LENGTH = max([len(SecurityDomain[value]) for value in SecurityDomain._member_map_]) CONTENTCTL_MAX_STANZA_LENGTH = ES_MAX_STANZA_LENGTH - len(ES_SEARCH_STANZA_NAME_FORMAT_AFTER_CLONING_IN_PRODUCT_TEMPLATE.format(security_domain_value="X"*SECURITY_DOMAIN_MAX_LENGTH,search_name="")) -CONTENTCTL_MAX_SEARCH_NAME_LENGTH = CONTENTCTL_MAX_STANZA_LENGTH - len(CONTENTCTL_STANZA_NAME_FORMAT_TEMPLATE.format(app_label="ESCU", detection_name="")) \ No newline at end of file +CONTENTCTL_MAX_SEARCH_NAME_LENGTH = CONTENTCTL_MAX_STANZA_LENGTH - len(CONTENTCTL_DETECTION_STANZA_NAME_FORMAT_TEMPLATE.format(app_label="ESCU", detection_name="")) \ No newline at end of file diff --git a/contentctl/objects/investigation.py b/contentctl/objects/investigation.py index 6437b395..293e3331 100644 --- a/contentctl/objects/investigation.py +++ b/contentctl/objects/investigation.py @@ -5,8 +5,12 @@ from contentctl.objects.security_content_object import SecurityContentObject from contentctl.objects.enums import DataModel from contentctl.objects.investigation_tags import InvestigationTags - -from contentctl.objects.constants import CONTENTCTL_MAX_SEARCH_NAME_LENGTH +from contentctl.objects.constants import ( + CONTENTCTL_MAX_SEARCH_NAME_LENGTH, + CONTENTCTL_RESPONSE_TASK_NAME_FORMAT_TEMPLATE, + CONTENTCTL_MAX_STANZA_LENGTH +) +from contentctl.objects.config import CustomApp # TODO (#266): disable the use_enum_values configuration class Investigation(SecurityContentObject): @@ -39,6 +43,16 @@ def inputs(self)->List[str]: def lowercase_name(self)->str: return self.name.replace(' ', '_').replace('-','_').replace('.','_').replace('/','_').lower().replace(' ', '_').replace('-','_').replace('.','_').replace('/','_').lower() + + # This is a slightly modified version of the get_conf_stanza_name function from + # SecurityContentObject_Abstract + def get_response_task_name(self, app:CustomApp, max_stanza_length:int=CONTENTCTL_MAX_STANZA_LENGTH)->str: + stanza_name = CONTENTCTL_RESPONSE_TASK_NAME_FORMAT_TEMPLATE.format(app_label=app.label, detection_name=self.name) + if len(stanza_name) > max_stanza_length: + raise ValueError(f"conf stanza may only be {max_stanza_length} characters, " + f"but stanza was actually {len(stanza_name)} characters: '{stanza_name}' ") + return stanza_name + @model_serializer def serialize_model(self): @@ -69,6 +83,3 @@ def model_post_init(self, ctx:dict[str,Any]): # back to itself for story in self.tags.analytic_story: story.investigations.append(self) - - - \ No newline at end of file diff --git a/contentctl/objects/story.py b/contentctl/objects/story.py index 36558388..09d65287 100644 --- a/contentctl/objects/story.py +++ b/contentctl/objects/story.py @@ -8,26 +8,14 @@ from contentctl.objects.investigation import Investigation from contentctl.objects.baseline import Baseline from contentctl.objects.data_source import DataSource + from contentctl.objects.config import CustomApp from contentctl.objects.security_content_object import SecurityContentObject - - - - -#from contentctl.objects.investigation import Investigation - - - class Story(SecurityContentObject): narrative: str = Field(...) tags: StoryTags = Field(...) - # enrichments - #detection_names: List[str] = [] - #investigation_names: List[str] = [] - #baseline_names: List[str] = [] - # These are updated when detection and investigation objects are created. # Specifically in the model_post_init functions detections:List[Detection] = [] @@ -46,9 +34,9 @@ def data_sources(self)-> list[DataSource]: return sorted(list(data_source_objects)) - def storyAndInvestigationNamesWithApp(self, app_name:str)->List[str]: - return [f"{app_name} - {name} - Rule" for name in self.detection_names] + \ - [f"{app_name} - {name} - Response Task" for name in self.investigation_names] + def storyAndInvestigationNamesWithApp(self, app:CustomApp)->List[str]: + return [detection.get_conf_stanza_name(app) for detection in self.detections] + \ + [investigation.get_response_task_name(app) for investigation in self.investigations] @model_serializer def serialize_model(self): diff --git a/contentctl/output/templates/analyticstories_investigations.j2 b/contentctl/output/templates/analyticstories_investigations.j2 index 7362d005..3dac82c0 100644 --- a/contentctl/output/templates/analyticstories_investigations.j2 +++ b/contentctl/output/templates/analyticstories_investigations.j2 @@ -1,13 +1,13 @@ ### RESPONSE TASKS ### -{% for detection in objects %} -{% if (detection.type == 'Investigation') %} -[savedsearch://{{ detection.get_conf_stanza_name(app) }}] +{% for investigation in objects %} +{% if (investigation.type == 'Investigation') %} +[savedsearch://{{ investigation.get_response_task_name(app) }}] type = investigation explanation = none -{% if detection.how_to_implement is defined %} -how_to_implement = {{ detection.how_to_implement | escapeNewlines() }} +{% if investigation.how_to_implement is defined %} +how_to_implement = {{ investigation.how_to_implement | escapeNewlines() }} {% else %} how_to_implement = none {% endif %} diff --git a/contentctl/output/templates/analyticstories_stories.j2 b/contentctl/output/templates/analyticstories_stories.j2 index 165d676c..829bd8d0 100644 --- a/contentctl/output/templates/analyticstories_stories.j2 +++ b/contentctl/output/templates/analyticstories_stories.j2 @@ -10,7 +10,7 @@ version = {{ story.version }} references = {{ story.getReferencesListForJson() | tojson }} maintainers = [{"company": "{{ story.author_company }}", "email": "{{ story.author_email }}", "name": "{{ story.author_name }}"}] spec_version = 3 -searches = {{ story.storyAndInvestigationNamesWithApp(app.label) | tojson }} +searches = {{ story.storyAndInvestigationNamesWithApp(app) | tojson }} description = {{ story.description | escapeNewlines() }} {% if story.narrative is defined %} narrative = {{ story.narrative | escapeNewlines() }} diff --git a/contentctl/output/templates/savedsearches_investigations.j2 b/contentctl/output/templates/savedsearches_investigations.j2 index 6615af2d..42458dea 100644 --- a/contentctl/output/templates/savedsearches_investigations.j2 +++ b/contentctl/output/templates/savedsearches_investigations.j2 @@ -5,7 +5,7 @@ {% for detection in objects %} {% if (detection.type == 'Investigation') %} {% if detection.search is defined %} -[{{ detection.get_conf_stanza_name(app) }}] +[{{ detection.get_response_task_name(app) }}] action.escu = 0 action.escu.enabled = 1 action.escu.search_type = investigative From b488c22438a86f47b5ec55441b598843c3d6e1d3 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Wed, 25 Sep 2024 16:43:29 -0700 Subject: [PATCH 44/46] Update enum for RiskSeverity to fix bug with informational searches having wrong output (info) written to savedsearches.conf --- contentctl/objects/enums.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contentctl/objects/enums.py b/contentctl/objects/enums.py index 74d3ee7d..d7072ecc 100644 --- a/contentctl/objects/enums.py +++ b/contentctl/objects/enums.py @@ -415,7 +415,7 @@ class RiskSeverity(str,enum.Enum): # 60 - medium (41-60 for us) # 80 - high (61-80 for us) # 100 - critical (81 - 100 for us) - INFO = "info" + INFORMATIONAL = "informational" LOW = "low" MEDIUM = "medium" HIGH = "high" From a7b86ba53a5ed49a59624982b0a4fba2d04b576f Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Thu, 26 Sep 2024 09:13:05 -0700 Subject: [PATCH 45/46] update risk level reference --- contentctl/objects/detection_tags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contentctl/objects/detection_tags.py b/contentctl/objects/detection_tags.py index 71925a22..c8dce678 100644 --- a/contentctl/objects/detection_tags.py +++ b/contentctl/objects/detection_tags.py @@ -54,7 +54,7 @@ def risk_score(self) -> int: @property def severity(self)->RiskSeverity: if 0 <= self.risk_score <= 20: - return RiskSeverity.INFO + return RiskSeverity.INFORMATIONAL elif 20 < self.risk_score <= 40: return RiskSeverity.LOW elif 40 < self.risk_score <= 60: From 0b911b647eb7ea3939812f368c057def66b60325 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Thu, 26 Sep 2024 09:17:52 -0700 Subject: [PATCH 46/46] bump to release version 4.4.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index f368b66e..1db114c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "contentctl" -version = "4.3.5" +version = "4.4.0" description = "Splunk Content Control Tool" authors = ["STRT "] license = "Apache 2.0"