From a4532376db53ab22ac06f852eca62a3da0508358 Mon Sep 17 00:00:00 2001 From: Mathieu Gonzales Date: Wed, 14 Aug 2024 11:46:06 +0200 Subject: [PATCH 01/20] replaced class Config by ConfigDict --- .../infrastructures/DetectionTestingInfrastructure.py | 10 +++------- .../views/DetectionTestingViewWeb.py | 5 ++--- contentctl/enrichments/cve_enrichment.py | 10 +++------- contentctl/objects/base_test_result.py | 11 ++++------- contentctl/objects/correlation_search.py | 9 ++++----- contentctl/objects/notable_event.py | 11 +++++------ contentctl/objects/risk_event.py | 11 +++++------ contentctl/objects/ssa_detection.py | 5 ++--- 8 files changed, 28 insertions(+), 44 deletions(-) diff --git a/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py b/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py index ff4dcaa1..93bd08d9 100644 --- a/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +++ b/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py @@ -14,7 +14,7 @@ from shutil import copyfile from typing import Union, Optional -from pydantic import BaseModel, PrivateAttr, Field, dataclasses +from pydantic import ConfigDict, BaseModel, PrivateAttr, Field, dataclasses import requests # type: ignore import splunklib.client as client # type: ignore from splunklib.binding import HTTPError # type: ignore @@ -49,9 +49,7 @@ class SetupTestGroupResults(BaseModel): success: bool = True duration: float = 0 start_time: float - - class Config: - arbitrary_types_allowed = True + model_config = ConfigDict(arbitrary_types_allowed=True) class CleanupTestGroupResults(BaseModel): @@ -86,9 +84,7 @@ class DetectionTestingInfrastructure(BaseModel, abc.ABC): _conn: client.Service = PrivateAttr() pbar: tqdm.tqdm = None start_time: Optional[float] = None - - class Config: - arbitrary_types_allowed = True + model_config = ConfigDict(arbitrary_types_allowed=True) def __init__(self, **data): super().__init__(**data) diff --git a/contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py b/contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py index 5e2e46c0..3cd8b1ed 100644 --- a/contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py +++ b/contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py @@ -7,6 +7,7 @@ import jinja2 import webbrowser from threading import Thread +from pydantic import ConfigDict DEFAULT_WEB_UI_PORT = 7999 @@ -100,9 +101,7 @@ def log_exception(*args, **kwargs): class DetectionTestingViewWeb(DetectionTestingView): bottleApp: Bottle = Bottle() server: SimpleWebServer = SimpleWebServer(host="0.0.0.0", port=DEFAULT_WEB_UI_PORT) - - class Config: - arbitrary_types_allowed = True + model_config = ConfigDict(arbitrary_types_allowed=True) def setup(self): self.bottleApp.route("/", callback=self.showStatus) diff --git a/contentctl/enrichments/cve_enrichment.py b/contentctl/enrichments/cve_enrichment.py index 8d76f192..a03f55d6 100644 --- a/contentctl/enrichments/cve_enrichment.py +++ b/contentctl/enrichments/cve_enrichment.py @@ -5,7 +5,7 @@ import shelve import time from typing import Annotated, Any, Union, TYPE_CHECKING -from pydantic import BaseModel,Field, computed_field +from pydantic import ConfigDict, BaseModel,Field, computed_field from decimal import Decimal from requests.exceptions import ReadTimeout @@ -33,12 +33,8 @@ class CveEnrichment(BaseModel): use_enrichment: bool = True cve_api_obj: Union[CVESearch,None] = None - - class Config: - # Arbitrary_types are allowed to let us use the CVESearch Object - arbitrary_types_allowed = True - frozen = True - + # Arbitrary_types are allowed to let us use the CVESearch Object + model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) @staticmethod def getCveEnrichment(config:validate, timeout_seconds:int=10, force_disable_enrichment:bool=True)->CveEnrichment: diff --git a/contentctl/objects/base_test_result.py b/contentctl/objects/base_test_result.py index 6a0e629a..3dd2ece9 100644 --- a/contentctl/objects/base_test_result.py +++ b/contentctl/objects/base_test_result.py @@ -1,7 +1,7 @@ from typing import Union, Any from enum import Enum -from pydantic import BaseModel +from pydantic import ConfigDict, BaseModel from splunklib.data import Record from contentctl.helper.utils import Utils @@ -51,12 +51,9 @@ class BaseTestResult(BaseModel): # The Splunk endpoint URL sid_link: Union[None, str] = None - - class Config: - validate_assignment = True - - # Needed to allow for embedding of Exceptions in the model - arbitrary_types_allowed = True + + # Needed to allow for embedding of Exceptions in the model + model_config = ConfigDict(validate_assignment=True, arbitrary_types_allowed=True) @property def passed(self) -> bool: diff --git a/contentctl/objects/correlation_search.py b/contentctl/objects/correlation_search.py index 25ab865b..93642b37 100644 --- a/contentctl/objects/correlation_search.py +++ b/contentctl/objects/correlation_search.py @@ -4,7 +4,7 @@ from typing import Union, Optional, Any from enum import Enum -from pydantic import BaseModel, validator, Field, PrivateAttr +from pydantic import ConfigDict, BaseModel, computed_field, Field, PrivateAttr from splunklib.results import JSONResultsReader, Message # type: ignore from splunklib.binding import HTTPError, ResponseReader # type: ignore import splunklib.client as splunklib # type: ignore @@ -177,10 +177,9 @@ class PbarData(BaseModel): pbar: tqdm fq_test_name: str start_time: float - - class Config: - # needed to support the tqdm type - arbitrary_types_allowed = True + + # needed to support the tqdm type + model_config = ConfigDict(arbitrary_types_allowed=True) class CorrelationSearch(BaseModel): diff --git a/contentctl/objects/notable_event.py b/contentctl/objects/notable_event.py index d28d4a62..51053255 100644 --- a/contentctl/objects/notable_event.py +++ b/contentctl/objects/notable_event.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import ConfigDict, BaseModel from contentctl.objects.detection import Detection @@ -10,11 +10,10 @@ class NotableEvent(BaseModel): # The search ID that found that generated this risk event orig_sid: str - - class Config: - # Allowing fields that aren't explicitly defined to be passed since some of the risk event's - # fields vary depending on the SPL which generated them - extra = 'allow' + + # Allowing fields that aren't explicitly defined to be passed since some of the risk event's + # fields vary depending on the SPL which generated them + model_config = ConfigDict(extra='allow') def validate_against_detection(self, detection: Detection) -> None: raise NotImplementedError() diff --git a/contentctl/objects/risk_event.py b/contentctl/objects/risk_event.py index 0bfff138..b11b9e27 100644 --- a/contentctl/objects/risk_event.py +++ b/contentctl/objects/risk_event.py @@ -1,7 +1,7 @@ import re from typing import Union, Optional -from pydantic import BaseModel, Field, PrivateAttr, field_validator +from pydantic import ConfigDict, BaseModel, Field, PrivateAttr, field_validator from contentctl.objects.errors import ValidationFailed from contentctl.objects.detection import Detection @@ -61,11 +61,10 @@ class RiskEvent(BaseModel): # Private attribute caching the observable this RiskEvent is mapped to _matched_observable: Optional[Observable] = PrivateAttr(default=None) - - class Config: - # Allowing fields that aren't explicitly defined to be passed since some of the risk event's - # fields vary depending on the SPL which generated them - extra = "allow" + + # Allowing fields that aren't explicitly defined to be passed since some of the risk event's + # fields vary depending on the SPL which generated them + model_config = ConfigDict(extra="allow") @field_validator("annotations_mitre_attack", "analyticstories", mode="before") @classmethod diff --git a/contentctl/objects/ssa_detection.py b/contentctl/objects/ssa_detection.py index 036f0b77..d467ea47 100644 --- a/contentctl/objects/ssa_detection.py +++ b/contentctl/objects/ssa_detection.py @@ -3,7 +3,7 @@ import string import requests import time -from pydantic import BaseModel, validator, root_validator +from pydantic import ConfigDict, BaseModel, validator, root_validator from dataclasses import dataclass from datetime import datetime from typing import Union @@ -59,8 +59,7 @@ class SSADetection(BaseModel): # raise ValueError('name is longer then 67 chars: ' + v) # return v - class Config: - use_enum_values = True + model_config = ConfigDict(use_enum_values=True) ''' @validator("name") From 25601d9784d80c931bd9f9ac25506bb30ca044c1 Mon Sep 17 00:00:00 2001 From: Mathieu Gonzales Date: Wed, 14 Aug 2024 11:46:53 +0200 Subject: [PATCH 02/20] replaced deprecated Pydantic v1 validators --- contentctl/objects/correlation_search.py | 121 +++++---------------- contentctl/objects/risk_analysis_action.py | 14 +-- contentctl/objects/ssa_detection_tags.py | 75 +++---------- 3 files changed, 52 insertions(+), 158 deletions(-) diff --git a/contentctl/objects/correlation_search.py b/contentctl/objects/correlation_search.py index 93642b37..a4854cb6 100644 --- a/contentctl/objects/correlation_search.py +++ b/contentctl/objects/correlation_search.py @@ -218,117 +218,52 @@ class CorrelationSearch(BaseModel): logger: logging.Logger = Field(default_factory=get_logger) # The search name (e.g. "ESCU - Windows Modify Registry EnableLinkedConnections - Rule") - name: Optional[str] = None + @computed_field + @property + def name(self) -> str: + return f"ESCU - {self.detection.name} - Rule" # The path to the saved search on the Splunk instance - splunk_path: Optional[str] = None + @computed_field + @property + def splunk_path(self) -> str: + return f"/saved/searches/{self.name}" # A model of the saved search as provided by splunklib - saved_search: Optional[splunklib.SavedSearch] = None + @computed_field + @property + def saved_search(self) -> splunklib.SavedSearch | None: + return splunklib.SavedSearch( + self.service, + self.splunk_path, + ) # The set of indexes to clear on cleanup indexes_to_purge: set[str] = set() # The risk analysis adaptive response action (if defined) - risk_analysis_action: Union[RiskAnalysisAction, None] = None + @computed_field + @property + def risk_analysis_action(self) -> RiskAnalysisAction | None: + if not self.saved_search.content: + return None + return CorrelationSearch._get_risk_analysis_action(self.saved_search.content) # The notable adaptive response action (if defined) - notable_action: Union[NotableAction, None] = None + @computed_field + @property + def notable_action(self) -> NotableAction | None: + if not self.saved_search.content: + return None + return CorrelationSearch._get_notable_action(self.saved_search.content) # The list of risk events found _risk_events: Optional[list[RiskEvent]] = PrivateAttr(default=None) # The list of notable events found _notable_events: Optional[list[NotableEvent]] = PrivateAttr(default=None) + model_config = ConfigDict(arbitrary_types_allowed=True, extra='forbid') - class Config: - # needed to allow fields w/ types like SavedSearch - arbitrary_types_allowed = True - # We want to have more ridgid typing - extra = 'forbid' - - @validator("name", always=True) - @classmethod - def _convert_detection_to_search_name(cls, v, values) -> str: - """ - Validate name and derive if None - """ - if "detection" not in values: - raise ValueError("detection missing; name is dependent on detection") - - expected_name = f"ESCU - {values['detection'].name} - Rule" - if v is not None and v != expected_name: - raise ValueError( - "name must be derived from detection; leave as None and it will be derived automatically" - ) - return expected_name - - @validator("splunk_path", always=True) - @classmethod - def _derive_splunk_path(cls, v, values) -> str: - """ - Validate splunk_path and derive if None - """ - if "name" not in values: - raise ValueError("name missing; splunk_path is dependent on name") - - expected_path = f"saved/searches/{values['name']}" - if v is not None and v != expected_path: - raise ValueError( - "splunk_path must be derived from name; leave as None and it will be derived automatically" - ) - return f"saved/searches/{values['name']}" - - @validator("saved_search", always=True) - @classmethod - def _instantiate_saved_search(cls, v, values) -> str: - """ - Ensure saved_search was initialized as None and derive - """ - if "splunk_path" not in values or "service" not in values: - raise ValueError("splunk_path or service missing; saved_search is dependent on both") - - if v is not None: - raise ValueError( - "saved_search must be derived from the service and splunk_path; leave as None and it will be derived " - "automatically" - ) - return splunklib.SavedSearch( - values['service'], - values['splunk_path'], - ) - - @validator("risk_analysis_action", always=True) - @classmethod - def _init_risk_analysis_action(cls, v, values) -> Optional[RiskAnalysisAction]: - """ - Initialize risk_analysis_action - """ - if "saved_search" not in values: - raise ValueError("saved_search missing; risk_analysis_action is dependent on saved_search") - - if v is not None: - raise ValueError( - "risk_analysis_action must be derived from the saved_search; leave as None and it will be derived " - "automatically" - ) - return CorrelationSearch._get_risk_analysis_action(values['saved_search'].content) - - @validator("notable_action", always=True) - @classmethod - def _init_notable_action(cls, v, values) -> Optional[NotableAction]: - """ - Initialize notable_action - """ - if "saved_search" not in values: - raise ValueError("saved_search missing; notable_action is dependent on saved_search") - - if v is not None: - raise ValueError( - "notable_action must be derived from the saved_search; leave as None and it will be derived " - "automatically" - ) - return CorrelationSearch._get_notable_action(values['saved_search'].content) @property def earliest_time(self) -> str: diff --git a/contentctl/objects/risk_analysis_action.py b/contentctl/objects/risk_analysis_action.py index e29939d3..b5123236 100644 --- a/contentctl/objects/risk_analysis_action.py +++ b/contentctl/objects/risk_analysis_action.py @@ -1,7 +1,7 @@ from typing import Any import json -from pydantic import BaseModel, validator +from pydantic import BaseModel, field_validator from contentctl.objects.risk_object import RiskObject from contentctl.objects.threat_object import ThreatObject @@ -21,32 +21,32 @@ class RiskAnalysisAction(BaseModel): risk_objects: list[RiskObject] message: str - @validator("message", always=True, pre=True) + @field_validator("message", mode="before") @classmethod - def _validate_message(cls, v, values) -> str: + def _validate_message(cls, message) -> str: """ Validate splunk_path and derive if None """ - if v is None: + if message is None: raise ValueError( "RiskAnalysisAction.message is a required field, cannot be None. Check the " "detection YAML definition to ensure a message is defined" ) - if not isinstance(v, str): + if not isinstance(message, str): raise ValueError( "RiskAnalysisAction.message must be a string. Check the detection YAML definition " "to ensure message is defined as a string" ) - if len(v.strip()) < 1: + if len(message.strip()) < 1: raise ValueError( "RiskAnalysisAction.message must be a meaningful string, with a length greater than" "or equal to 1 (once stripped of trailing/leading whitespace). Check the detection " "YAML definition to ensure message is defined as a meanigful string" ) - return v + return message @classmethod def parse_from_dict(cls, dict_: dict[str, Any]) -> "RiskAnalysisAction": diff --git a/contentctl/objects/ssa_detection_tags.py b/contentctl/objects/ssa_detection_tags.py index a2be2b20..c3b734d6 100644 --- a/contentctl/objects/ssa_detection_tags.py +++ b/contentctl/objects/ssa_detection_tags.py @@ -1,7 +1,6 @@ from __future__ import annotations -import re from typing import List -from pydantic import BaseModel, validator, ValidationError, model_validator, Field +from pydantic import BaseModel, computed_field, constr, field_validator, model_validator, Field from contentctl.objects.mitre_attack_enrichment import MitreAttackEnrichment from contentctl.objects.constants import * @@ -13,17 +12,19 @@ class SSADetectionTags(BaseModel): analytic_story: list asset_type: str automated_detection_testing: str = None - cis20: list = None - confidence: int - impact: int + cis20: list[constr(pattern=r"^CIS (\d|1\d|20)$")] = None #DO NOT match leading zeroes and ensure no extra characters before or after the string + confidence: int = Field(..., ge=1, le=100) + impact: int = Field(..., ge=1, le=100) kill_chain_phases: list = None message: str - mitre_attack_id: list = None + mitre_attack_id: list[constr(pattern=r"^T[0-9]{4}$")] = None nist: list = None observable: list product: List[SecurityContentProductName] = Field(...,min_length=1) required_fields: list - risk_score: int + @computed_field + def risk_score(self) -> int: + return round((self.confidence * self.impact)/100) security_domain: str risk_severity: str = None cve: list = None @@ -51,16 +52,9 @@ class SSADetectionTags(BaseModel): annotations: dict = None - @validator('cis20') - def tags_cis20(cls, v, values): - pattern = r'^CIS ([\d|1\d|20)$' #DO NOT match leading zeroes and ensure no extra characters before or after the string - for value in v: - if not re.match(pattern, value): - raise ValueError(f"CIS control '{value}' is not a valid Control ('CIS 1' -> 'CIS 20'): {values['name']}") - return v - @validator('nist') - def tags_nist(cls, v, values): + @field_validator('nist', mode='before') + def tags_nist(cls, nist): # Sourced Courtest of NIST: https://www.nist.gov/system/files/documents/cyberframework/cybersecurity-framework-021214.pdf (Page 19) IDENTIFY = [f'ID.{category}' for category in ["AM", "BE", "GV", "RA", "RM"] ] PROTECT = [f'PR.{category}' for category in ["AC", "AT", "DS", "IP", "MA", "PT"]] @@ -70,53 +64,18 @@ def tags_nist(cls, v, values): ALL_NIST_CATEGORIES = IDENTIFY + PROTECT + DETECT + RESPOND + RECOVER - for value in v: - if not value in ALL_NIST_CATEGORIES: + for value in nist: + if value not in ALL_NIST_CATEGORIES: raise ValueError(f"NIST Category '{value}' is not a valid category") - return v + return nist - @validator('confidence') - def tags_confidence(cls, v, values): - v = int(v) - if not (v > 0 and v <= 100): - raise ValueError('confidence score is out of range 1-100.' ) - else: - return v - - - @validator('impact') - def tags_impact(cls, v, values): - if not (v > 0 and v <= 100): - raise ValueError('impact score is out of range 1-100.') - else: - return v - - @validator('kill_chain_phases') - def tags_kill_chain_phases(cls, v, values): + @field_validator('kill_chain_phases') + def tags_kill_chain_phases(cls, kill_chain_phases): valid_kill_chain_phases = SES_KILL_CHAIN_MAPPINGS.keys() - for value in v: + for value in kill_chain_phases: if value not in valid_kill_chain_phases: raise ValueError('kill chain phase not valid. Valid options are ' + str(valid_kill_chain_phases)) - return v - - @validator('mitre_attack_id') - def tags_mitre_attack_id(cls, v, values): - pattern = 'T[0-9]{4}' - for value in v: - if not re.match(pattern, value): - raise ValueError('Mitre Attack ID are not following the pattern Txxxx:' ) - return v - - - - @validator('risk_score') - def tags_calculate_risk_score(cls, v, values): - calculated_risk_score = round(values['impact'] * values['confidence'] / 100) - if calculated_risk_score != int(v): - raise ValueError(f"Risk Score must be calculated as round(confidence * impact / 100)" - f"\n Expected risk_score={calculated_risk_score}, found risk_score={int(v)}: {values['name']}") - return v - + return kill_chain_phases @model_validator(mode="after") def tags_observable(self): From a1c0915ae5f85b125255ee94d9bda0001bb9c78b Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Thu, 22 Aug 2024 17:11:31 -0700 Subject: [PATCH 03/20] Beginnings of drilldown support --- contentctl/objects/detection_tags.py | 7 ++++--- contentctl/objects/drilldown.py | 6 ++++++ 2 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 contentctl/objects/drilldown.py diff --git a/contentctl/objects/detection_tags.py b/contentctl/objects/detection_tags.py index 78c82430..e9a7b2ac 100644 --- a/contentctl/objects/detection_tags.py +++ b/contentctl/objects/detection_tags.py @@ -33,7 +33,7 @@ SecurityContentProductName ) from contentctl.objects.atomic import AtomicTest - +from contentctl.objects.drilldown import Drilldown class DetectionTags(BaseModel): # detection spec @@ -70,7 +70,7 @@ def risk_severity(self) -> RiskSeverity: cve: List[Annotated[str, r"^CVE-[1|2]\d{3}-\d+$"]] = [] atomic_guid: List[AtomicTest] = [] - drilldown_search: Optional[str] = None + # enrichment mitre_attack_enrichments: List[MitreAttackEnrichment] = Field([], validate_default=True) @@ -107,7 +107,8 @@ def cis20(self) -> list[Cis18Value]: mappings: Optional[List] = None # annotations: Optional[dict] = None manual_test: Optional[str] = None - + drilldown: Drilldown | None = None + # The following validator is temporarily disabled pending further discussions # @validator('message') # def validate_message(cls,v,values): diff --git a/contentctl/objects/drilldown.py b/contentctl/objects/drilldown.py new file mode 100644 index 00000000..168f8a14 --- /dev/null +++ b/contentctl/objects/drilldown.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel, Field +class Drilldown(BaseModel): + name: str = Field(...,min_length=5) + search: str = Field(...,description= "The drilldown search. The drilldown MUST begin with the | character followed by a space.", pattern=r"^\|\s+.*") + earliest_offset:str = "$info_min_time$" + latest_offset:str = "$info_max_time$" \ No newline at end of file From 0b48ce4250c43562671bd60f35a807034c371171 Mon Sep 17 00:00:00 2001 From: pyth0n1c <87383215+pyth0n1c@users.noreply.github.com> Date: Tue, 27 Aug 2024 13:28:40 -0700 Subject: [PATCH 04/20] Relax requirement on search string from drilldown --- contentctl/objects/drilldown.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contentctl/objects/drilldown.py b/contentctl/objects/drilldown.py index 168f8a14..7d7b8155 100644 --- a/contentctl/objects/drilldown.py +++ b/contentctl/objects/drilldown.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, Field class Drilldown(BaseModel): name: str = Field(...,min_length=5) - search: str = Field(...,description= "The drilldown search. The drilldown MUST begin with the | character followed by a space.", pattern=r"^\|\s+.*") + search: str = Field(..., description="The text of a drilldown search. This must be valid SPL." min_length=1) earliest_offset:str = "$info_min_time$" latest_offset:str = "$info_max_time$" \ No newline at end of file From b3e7330c2bc71ee5054c8a9bc46f4456c7972d55 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Wed, 28 Aug 2024 12:09:26 -0700 Subject: [PATCH 05/20] more progess on drilldown updates --- .../infrastructures/DetectionTestingInfrastructure.py | 1 + contentctl/objects/drilldown.py | 2 +- contentctl/output/templates/savedsearches_detections.j2 | 8 +++++++- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py b/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py index 3454e782..49120ec6 100644 --- a/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +++ b/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py @@ -1180,6 +1180,7 @@ def retry_search_until_timeout( return def delete_attack_data(self, attack_data_files: list[UnitTestAttackData]): + return for attack_data_file in attack_data_files: index = attack_data_file.custom_index or self.sync_obj.replay_index host = attack_data_file.host or self.sync_obj.replay_host diff --git a/contentctl/objects/drilldown.py b/contentctl/objects/drilldown.py index 7d7b8155..6b5111a7 100644 --- a/contentctl/objects/drilldown.py +++ b/contentctl/objects/drilldown.py @@ -1,6 +1,6 @@ from pydantic import BaseModel, Field class Drilldown(BaseModel): name: str = Field(...,min_length=5) - search: str = Field(..., description="The text of a drilldown search. This must be valid SPL." min_length=1) + search: str = Field(..., description="The text of a drilldown search. This must be valid SPL.", min_length=1) earliest_offset:str = "$info_min_time$" latest_offset:str = "$info_max_time$" \ No newline at end of file diff --git a/contentctl/output/templates/savedsearches_detections.j2 b/contentctl/output/templates/savedsearches_detections.j2 index 1b66d452..cbce1f43 100644 --- a/contentctl/output/templates/savedsearches_detections.j2 +++ b/contentctl/output/templates/savedsearches_detections.j2 @@ -108,7 +108,13 @@ quantity = 0 realtime_schedule = 0 is_visible = false search = {{ detection.search | escapeNewlines() }} - +{% if detection.tags.drilldown%} +action.notable.param.drilldown_name = {{ detection.tags.drilldown.name }} +action.notable.param.drilldown_search = {{ detection.tags.drilldown.search | escapeNewlines()}} +action.notable.param.drilldown_earliest_offset = {{ detection.tags.drilldown.earliest_offset }} +action.notable.param.drilldown_latest_offset = {{ detection.tags.drilldown.latest_offset }} +{% endif %} {% endif %} + {% endfor %} ### END {{ APP_NAME }} DETECTIONS ### From 9ba9300981a2e072825d20703fa724160228ab5d Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Wed, 18 Sep 2024 13:15:52 -0700 Subject: [PATCH 06/20] Add the possibility to automatically create drilldowns. We will likely remove this, but let's keep it now for purposes of discussion. --- contentctl/contentctl.py | 5 ++- .../detection_abstract.py | 12 ++++++- contentctl/objects/detection_tags.py | 2 -- contentctl/objects/drilldown.py | 34 +++++++++++++++++-- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/contentctl/contentctl.py b/contentctl/contentctl.py index e6d8c5d7..f2cf1223 100644 --- a/contentctl/contentctl.py +++ b/contentctl/contentctl.py @@ -193,7 +193,10 @@ def main(): t.__dict__.update(config.__dict__) init_func(t) elif type(config) == validate: - validate_func(config) + v=validate_func(config) + import code + code.interact(local= + locals()) elif type(config) == report: report_func(config) elif type(config) == build: diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index bd4f83df..d440761b 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -35,7 +35,7 @@ from contentctl.objects.integration_test import IntegrationTest from contentctl.objects.data_source import DataSource from contentctl.objects.base_test_result import TestResultStatus - +from contentctl.objects.drilldown import Drilldown # from contentctl.objects.playbook import Playbook from contentctl.objects.enums import ProvidingTechnology from contentctl.enrichments.cve_enrichment import CveEnrichmentObj @@ -73,6 +73,7 @@ class Detection_Abstract(SecurityContentObject): test_groups: Union[list[TestGroup], None] = Field(None, validate_default=True) data_source_objects: list[DataSource] = [] + drilldown_searches: list[Drilldown] = Field(default=[], description="A list of Drilldowns that should be included with this search") @field_validator("search", mode="before") @classmethod @@ -525,6 +526,15 @@ def model_post_init(self, __context: Any) -> None: # Derive TestGroups and IntegrationTests, adjust for ManualTests, skip as needed self.adjust_tests_and_groups() + #Add the default drilldown + #self.drilldown_searches.append(Drilldown.constructDrilldownFromDetection(self)) + + # Update the search fields with the original search, if required + for drilldown in self.drilldown_searches: + drilldown.perform_search_substitutions(self) + print("adding default drilldown?") + self.drilldown_searches.append(Drilldown.constructDrilldownFromDetection(self)) + @field_validator('lookups', mode="before") @classmethod def getDetectionLookups(cls, v:list[str], info:ValidationInfo) -> list[Lookup]: diff --git a/contentctl/objects/detection_tags.py b/contentctl/objects/detection_tags.py index 4b05b8c0..5799afd6 100644 --- a/contentctl/objects/detection_tags.py +++ b/contentctl/objects/detection_tags.py @@ -33,7 +33,6 @@ SecurityContentProductName ) from contentctl.objects.atomic import AtomicTest -from contentctl.objects.drilldown import Drilldown from contentctl.objects.annotated_types import MITRE_ATTACK_ID_TYPE, CVE_TYPE # TODO (#266): disable the use_enum_values configuration @@ -113,7 +112,6 @@ def cis20(self) -> list[Cis18Value]: # TODO (#268): Validate manual_test has length > 0 if not None manual_test: Optional[str] = None - drilldown: Drilldown | None = None # The following validator is temporarily disabled pending further discussions # @validator('message') diff --git a/contentctl/objects/drilldown.py b/contentctl/objects/drilldown.py index 6b5111a7..30c86302 100644 --- a/contentctl/objects/drilldown.py +++ b/contentctl/objects/drilldown.py @@ -1,6 +1,34 @@ +from __future__ import annotations from pydantic import BaseModel, Field +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from contentctl.objects.detection import Detection +from contentctl.objects.enums import AnalyticsType +SEARCH_PLACEHOLDER = "%original_detection_search%" + class Drilldown(BaseModel): - name: str = Field(...,min_length=5) + name: str = Field(..., description="The name of the drilldown search", min_length=5) search: str = Field(..., description="The text of a drilldown search. This must be valid SPL.", min_length=1) - earliest_offset:str = "$info_min_time$" - latest_offset:str = "$info_max_time$" \ No newline at end of file + earliest_offset:str = Field(default="$info_min_time$", description="Earliest offset time for the drilldown search", min_length= 1) + latest_offset:str = Field(default="$info_max_time$", description="Latest offset time for the driolldown search", min_length= 1) + + @classmethod + def constructDrilldownFromDetection(cls, detection: Detection) -> Drilldown: + if len([f"${o.name}$" for o in detection.tags.observable if o.role[0] == "Victim"]) == 0 and detection.type != AnalyticsType.Hunting: + print("no victim!") + # print(detection.tags.observable) + # print(detection.file_path) + name_field = "View the detection results for " + ' and ' + ''.join([f"${o.name}$" for o in detection.tags.observable if o.type[0] == "Victim"]) + search_field = f"{detection.search} | search " + ' '.join([f"o.name = ${o.name}$" for o in detection.tags.observable]) + return cls(name=name_field, search=search_field) + + + def perform_search_substitutions(self, detection:Detection)->None: + if (self.search.count("%") % 2) or (self.search.count("$") % 2): + print("\n\nWarning - a non-even number of '%' or '$' characters were found in the\n" + f"drilldown search '{self.search}' for Detection {detection.file_path}.\n" + "If this was intentional, then please ignore this warning.\n") + self.search = self.search.replace(SEARCH_PLACEHOLDER, detection.search) + + + From 20e884001e130208aeed69f5e54b1c882dba23dd Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Thu, 26 Sep 2024 15:51:18 -0700 Subject: [PATCH 07/20] remove deffault values for earliesT_offset and latest_offset. These values must be supplied explicitly. --- contentctl/objects/drilldown.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/contentctl/objects/drilldown.py b/contentctl/objects/drilldown.py index 30c86302..3d1f1148 100644 --- a/contentctl/objects/drilldown.py +++ b/contentctl/objects/drilldown.py @@ -9,8 +9,16 @@ class Drilldown(BaseModel): name: str = Field(..., description="The name of the drilldown search", min_length=5) search: str = Field(..., description="The text of a drilldown search. This must be valid SPL.", min_length=1) - earliest_offset:str = Field(default="$info_min_time$", description="Earliest offset time for the drilldown search", min_length= 1) - latest_offset:str = Field(default="$info_max_time$", description="Latest offset time for the driolldown search", min_length= 1) + earliest_offset:str = Field(..., + description="Earliest offset time for the drilldown search. " + "The most common value for this field is '$info_min_time$', " + "but it is NOT the default value and must be supplied explicitly.", + min_length= 1) + latest_offset:str = Field(..., + description="Latest offset time for the driolldown search. " + "The most common value for this field is '$info_max_time$', " + "but it is NOT the default value and must be supplied explicitly.", + min_length= 1) @classmethod def constructDrilldownFromDetection(cls, detection: Detection) -> Drilldown: From a849e34b1f2a8fa5b1cc0b76357b7ac26c4d3b7f Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Thu, 26 Sep 2024 16:17:33 -0700 Subject: [PATCH 08/20] experimenting with updating the drilldowns and generating defaults --- .../detection_abstract.py | 2 +- contentctl/objects/drilldown.py | 25 ++++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index ef55bdb3..a0ae52dc 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -571,7 +571,7 @@ def model_post_init(self, __context: Any) -> None: for drilldown in self.drilldown_searches: drilldown.perform_search_substitutions(self) print("adding default drilldown?") - self.drilldown_searches.append(Drilldown.constructDrilldownFromDetection(self)) + self.drilldown_searches.extend(Drilldown.constructDrilldownsFromDetection(self)) @field_validator('lookups', mode="before") @classmethod diff --git a/contentctl/objects/drilldown.py b/contentctl/objects/drilldown.py index 3d1f1148..4b0cfc68 100644 --- a/contentctl/objects/drilldown.py +++ b/contentctl/objects/drilldown.py @@ -5,30 +5,43 @@ from contentctl.objects.detection import Detection from contentctl.objects.enums import AnalyticsType SEARCH_PLACEHOLDER = "%original_detection_search%" +EARLIEST_OFFSET = "$info_min_time$" +LATEST_OFFSET = "$info_max_time$" +RISK_SEARCH = "index = risk | stats count values(search_name) values(risk_message) values(analyticstories) values(annotations._all) values(annotations.mitre_attack.mitre_tactic) by risk_object" class Drilldown(BaseModel): name: str = Field(..., description="The name of the drilldown search", min_length=5) search: str = Field(..., description="The text of a drilldown search. This must be valid SPL.", min_length=1) earliest_offset:str = Field(..., description="Earliest offset time for the drilldown search. " - "The most common value for this field is '$info_min_time$', " + f"The most common value for this field is '{EARLIEST_OFFSET}', " "but it is NOT the default value and must be supplied explicitly.", min_length= 1) latest_offset:str = Field(..., description="Latest offset time for the driolldown search. " - "The most common value for this field is '$info_max_time$', " + f"The most common value for this field is '{LATEST_OFFSET}', " "but it is NOT the default value and must be supplied explicitly.", min_length= 1) @classmethod - def constructDrilldownFromDetection(cls, detection: Detection) -> Drilldown: + def constructDrilldownsFromDetection(cls, detection: Detection) -> list[Drilldown]: if len([f"${o.name}$" for o in detection.tags.observable if o.role[0] == "Victim"]) == 0 and detection.type != AnalyticsType.Hunting: print("no victim!") # print(detection.tags.observable) # print(detection.file_path) - name_field = "View the detection results for " + ' and ' + ''.join([f"${o.name}$" for o in detection.tags.observable if o.type[0] == "Victim"]) - search_field = f"{detection.search} | search " + ' '.join([f"o.name = ${o.name}$" for o in detection.tags.observable]) - return cls(name=name_field, search=search_field) + + variableNamesString = ' and'.join([f"${o.name}$" for o in detection.tags.observable if o.type[0] == "Victim"]) + nameField = "View the detection results for }" + variableNamesString + appendedSearch = " | search " + ' '.join([f"o.name = ${o.name}$" for o in detection.tags.observable]) + search_field = f"{detection.search}{appendedSearch}" + detection_results = cls(name=nameField, earliest_offset=EARLIEST_OFFSET, latest_offset=LATEST_OFFSET, search=search_field) + + + nameField = f"View risk events for the last 7 days for {variableNamesString}" + search_field = f"{RISK_SEARCH}{appendedSearch}" + risk_events_last_7_days = cls(name=nameField, earliest_offset=EARLIEST_OFFSET, latest_offset=LATEST_OFFSET, search=search_field) + + return [detection_results,risk_events_last_7_days] def perform_search_substitutions(self, detection:Detection)->None: From 7bde9d72f515c1cfe6e937f51f107e7b8e0f2b6c Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Thu, 26 Sep 2024 16:36:07 -0700 Subject: [PATCH 09/20] Update template and drilldown object --- contentctl/objects/drilldown.py | 14 +++++++------- .../output/templates/savedsearches_detections.j2 | 12 ++++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/contentctl/objects/drilldown.py b/contentctl/objects/drilldown.py index 4b0cfc68..754b8add 100644 --- a/contentctl/objects/drilldown.py +++ b/contentctl/objects/drilldown.py @@ -25,14 +25,14 @@ class Drilldown(BaseModel): @classmethod def constructDrilldownsFromDetection(cls, detection: Detection) -> list[Drilldown]: - if len([f"${o.name}$" for o in detection.tags.observable if o.role[0] == "Victim"]) == 0 and detection.type != AnalyticsType.Hunting: - print("no victim!") - # print(detection.tags.observable) - # print(detection.file_path) + victim_observables = [o for o in detection.tags.observable if o.role[0] == "Victim"] + if len(victim_observables) == 0 or detection.type == AnalyticsType.Hunting: + # No victims, so no drilldowns + return [] - variableNamesString = ' and'.join([f"${o.name}$" for o in detection.tags.observable if o.type[0] == "Victim"]) - nameField = "View the detection results for }" + variableNamesString - appendedSearch = " | search " + ' '.join([f"o.name = ${o.name}$" for o in detection.tags.observable]) + variableNamesString = ' and '.join([f"${o.name}$" for o in victim_observables]) + nameField = f"View the detection results for {variableNamesString}" + appendedSearch = " | search " + ' '.join([f"{o.name} = ${o.name}$" for o in victim_observables]) search_field = f"{detection.search}{appendedSearch}" detection_results = cls(name=nameField, earliest_offset=EARLIEST_OFFSET, latest_offset=LATEST_OFFSET, search=search_field) diff --git a/contentctl/output/templates/savedsearches_detections.j2 b/contentctl/output/templates/savedsearches_detections.j2 index 16811a9f..a5151a09 100644 --- a/contentctl/output/templates/savedsearches_detections.j2 +++ b/contentctl/output/templates/savedsearches_detections.j2 @@ -112,12 +112,12 @@ alert.suppress.fields = {{ detection.tags.throttling.conf_formatted_fields() }} alert.suppress.period = {{ detection.tags.throttling.period }} {% endif %} search = {{ detection.search | escapeNewlines() }} -{% if detection.tags.drilldown%} -action.notable.param.drilldown_name = {{ detection.tags.drilldown.name }} -action.notable.param.drilldown_search = {{ detection.tags.drilldown.search | escapeNewlines()}} -action.notable.param.drilldown_earliest_offset = {{ detection.tags.drilldown.earliest_offset }} -action.notable.param.drilldown_latest_offset = {{ detection.tags.drilldown.latest_offset }} -{% endif %} +{% for drilldown_search in detection.drilldown_searches%} +action.notable.param.drilldown_name = {{ drilldown_search.name }} +action.notable.param.drilldown_search = {{ drilldown_search.search | escapeNewlines()}} +action.notable.param.drilldown_earliest_offset = {{ drilldown_search.earliest_offset }} +action.notable.param.drilldown_latest_offset = {{ drilldown_search.latest_offset }} +{% endfor %} {% endif %} {% endfor %} From c0cff81e9be029877671bee403e9d7f8f2de6fff Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Thu, 26 Sep 2024 18:53:52 -0700 Subject: [PATCH 10/20] Switch drilldowns to dump in json format so we can support and arbitrary number of them --- .../detection_abstract.py | 4 ++++ contentctl/objects/drilldown.py | 4 +--- contentctl/output/templates/savedsearches_detections.j2 | 7 +------ 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index a0ae52dc..26ff2160 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -573,6 +573,10 @@ def model_post_init(self, __context: Any) -> None: print("adding default drilldown?") self.drilldown_searches.extend(Drilldown.constructDrilldownsFromDetection(self)) + @property + def drilldownsInJSON(self) -> list[dict[str,str]]: + return [drilldown.model_dump() for drilldown in self.drilldown_searches] + @field_validator('lookups', mode="before") @classmethod def getDetectionLookups(cls, v:list[str], info:ValidationInfo) -> list[Lookup]: diff --git a/contentctl/objects/drilldown.py b/contentctl/objects/drilldown.py index 754b8add..d0f1b07d 100644 --- a/contentctl/objects/drilldown.py +++ b/contentctl/objects/drilldown.py @@ -50,6 +50,4 @@ def perform_search_substitutions(self, detection:Detection)->None: f"drilldown search '{self.search}' for Detection {detection.file_path}.\n" "If this was intentional, then please ignore this warning.\n") self.search = self.search.replace(SEARCH_PLACEHOLDER, detection.search) - - - + \ No newline at end of file diff --git a/contentctl/output/templates/savedsearches_detections.j2 b/contentctl/output/templates/savedsearches_detections.j2 index a5151a09..29a6a59c 100644 --- a/contentctl/output/templates/savedsearches_detections.j2 +++ b/contentctl/output/templates/savedsearches_detections.j2 @@ -112,12 +112,7 @@ alert.suppress.fields = {{ detection.tags.throttling.conf_formatted_fields() }} alert.suppress.period = {{ detection.tags.throttling.period }} {% endif %} search = {{ detection.search | escapeNewlines() }} -{% for drilldown_search in detection.drilldown_searches%} -action.notable.param.drilldown_name = {{ drilldown_search.name }} -action.notable.param.drilldown_search = {{ drilldown_search.search | escapeNewlines()}} -action.notable.param.drilldown_earliest_offset = {{ drilldown_search.earliest_offset }} -action.notable.param.drilldown_latest_offset = {{ drilldown_search.latest_offset }} -{% endfor %} +action.notable.param.drilldown_searches = {{ detection.drilldownsInJSON | tojson | escapeNewlines() }} {% endif %} {% endfor %} From 5ca8adea72ff4eb65fea33e0ae6e4bb8df2ae7e7 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Fri, 27 Sep 2024 08:44:41 -0700 Subject: [PATCH 11/20] Fix serialization issue with drilldowns. Format of multiple drilldowns in savedsearches.conf is now correct. We are still populating the default drilldowns, this feature will eventually be removed. --- .../detection_abstract.py | 14 +++++++-- contentctl/objects/drilldown.py | 31 ++++++++++++++----- .../templates/savedsearches_detections.j2 | 2 +- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index 26ff2160..d10d36dd 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -570,11 +570,21 @@ def model_post_init(self, __context: Any) -> None: # Update the search fields with the original search, if required for drilldown in self.drilldown_searches: drilldown.perform_search_substitutions(self) - print("adding default drilldown?") + + #For experimental purposes, add the default drilldowns self.drilldown_searches.extend(Drilldown.constructDrilldownsFromDetection(self)) @property - def drilldownsInJSON(self) -> list[dict[str,str]]: + def drilldowns_in_JSON(self) -> list[dict[str,str]]: + """This function is required for proper JSON + serializiation of drilldowns to occur in savedsearches.conf. + It returns the list[Drilldown] as a list[dict]. + Without this function, the jinja template is unable + to convert list[Drilldown] to JSON + + Returns: + list[dict[str,str]]: List of Drilldowns dumped to dict format + """ return [drilldown.model_dump() for drilldown in self.drilldown_searches] @field_validator('lookups', mode="before") diff --git a/contentctl/objects/drilldown.py b/contentctl/objects/drilldown.py index d0f1b07d..4b1fa49b 100644 --- a/contentctl/objects/drilldown.py +++ b/contentctl/objects/drilldown.py @@ -1,5 +1,5 @@ from __future__ import annotations -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, model_serializer from typing import TYPE_CHECKING if TYPE_CHECKING: from contentctl.objects.detection import Detection @@ -7,17 +7,17 @@ SEARCH_PLACEHOLDER = "%original_detection_search%" EARLIEST_OFFSET = "$info_min_time$" LATEST_OFFSET = "$info_max_time$" -RISK_SEARCH = "index = risk | stats count values(search_name) values(risk_message) values(analyticstories) values(annotations._all) values(annotations.mitre_attack.mitre_tactic) by risk_object" +RISK_SEARCH = "index = risk starthoursago = 168 endhoursago = 0 | stats count values(search_name) values(risk_message) values(analyticstories) values(annotations._all) values(annotations.mitre_attack.mitre_tactic) " class Drilldown(BaseModel): name: str = Field(..., description="The name of the drilldown search", min_length=5) search: str = Field(..., description="The text of a drilldown search. This must be valid SPL.", min_length=1) - earliest_offset:str = Field(..., + earliest_offset:None | str = Field(..., description="Earliest offset time for the drilldown search. " f"The most common value for this field is '{EARLIEST_OFFSET}', " "but it is NOT the default value and must be supplied explicitly.", min_length= 1) - latest_offset:str = Field(..., + latest_offset:None | str = Field(..., description="Latest offset time for the driolldown search. " f"The most common value for this field is '{LATEST_OFFSET}', " "but it is NOT the default value and must be supplied explicitly.", @@ -29,7 +29,7 @@ def constructDrilldownsFromDetection(cls, detection: Detection) -> list[Drilldow if len(victim_observables) == 0 or detection.type == AnalyticsType.Hunting: # No victims, so no drilldowns return [] - + print("Adding default drilldowns. REMOVE THIS BEFORE MERGING") variableNamesString = ' and '.join([f"${o.name}$" for o in victim_observables]) nameField = f"View the detection results for {variableNamesString}" appendedSearch = " | search " + ' '.join([f"{o.name} = ${o.name}$" for o in victim_observables]) @@ -38,8 +38,10 @@ def constructDrilldownsFromDetection(cls, detection: Detection) -> list[Drilldow nameField = f"View risk events for the last 7 days for {variableNamesString}" - search_field = f"{RISK_SEARCH}{appendedSearch}" - risk_events_last_7_days = cls(name=nameField, earliest_offset=EARLIEST_OFFSET, latest_offset=LATEST_OFFSET, search=search_field) + fieldNamesListString = ', '.join([o.name for o in victim_observables]) + search_field = f"{RISK_SEARCH}by {fieldNamesListString} {appendedSearch}" + #risk_events_last_7_days = cls(name=nameField, earliest_offset=EARLIEST_OFFSET, latest_offset=LATEST_OFFSET, search=search_field) + risk_events_last_7_days = cls(name=nameField, earliest_offset=None, latest_offset=None, search=search_field) return [detection_results,risk_events_last_7_days] @@ -50,4 +52,17 @@ def perform_search_substitutions(self, detection:Detection)->None: f"drilldown search '{self.search}' for Detection {detection.file_path}.\n" "If this was intentional, then please ignore this warning.\n") self.search = self.search.replace(SEARCH_PLACEHOLDER, detection.search) - \ No newline at end of file + + + @model_serializer + def serialize_model(self) -> dict[str,str]: + #Call serializer for parent + model:dict[str,str] = {} + + model['name'] = self.name + model['search'] = self.search + if self.earliest_offset is not None: + model['earliest_offset'] = self.earliest_offset + if self.latest_offset is not None: + model['latest_offset'] = self.latest_offset + return model \ No newline at end of file diff --git a/contentctl/output/templates/savedsearches_detections.j2 b/contentctl/output/templates/savedsearches_detections.j2 index 29a6a59c..396bb2c6 100644 --- a/contentctl/output/templates/savedsearches_detections.j2 +++ b/contentctl/output/templates/savedsearches_detections.j2 @@ -112,7 +112,7 @@ alert.suppress.fields = {{ detection.tags.throttling.conf_formatted_fields() }} alert.suppress.period = {{ detection.tags.throttling.period }} {% endif %} search = {{ detection.search | escapeNewlines() }} -action.notable.param.drilldown_searches = {{ detection.drilldownsInJSON | tojson | escapeNewlines() }} +action.notable.param.drilldown_searches = {{ detection.drilldowns_in_JSON | tojson | escapeNewlines() }} {% endif %} {% endfor %} From 3280fbf8163e665f290f8ca1d39a701f067c4deb Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Fri, 27 Sep 2024 10:54:09 -0700 Subject: [PATCH 12/20] remove some debugging --- contentctl/contentctl.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/contentctl/contentctl.py b/contentctl/contentctl.py index 44c2a559..dbf434a7 100644 --- a/contentctl/contentctl.py +++ b/contentctl/contentctl.py @@ -193,10 +193,7 @@ def main(): t.__dict__.update(config.__dict__) init_func(t) elif type(config) == validate: - v=validate_func(config) - import code - code.interact(local= - locals()) + validate_func(config) elif type(config) == report: report_func(config) elif type(config) == build: From db7de0bf5505079353b3aad2fb7b036c8ebcb222 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Tue, 1 Oct 2024 10:24:44 -0700 Subject: [PATCH 13/20] fixes to ensure that every search that needs one should have the appropriate default drilldown. --- .../detection_abstract.py | 29 +++++++++++++++---- contentctl/objects/drilldown.py | 18 +++++++----- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index d10d36dd..fb3cb6e2 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -36,7 +36,7 @@ from contentctl.objects.integration_test import IntegrationTest from contentctl.objects.data_source import DataSource from contentctl.objects.base_test_result import TestResultStatus -from contentctl.objects.drilldown import Drilldown +from contentctl.objects.drilldown import Drilldown, DRILLDOWN_SEARCH_PLACEHOLDER from contentctl.objects.enums import ProvidingTechnology from contentctl.enrichments.cve_enrichment import CveEnrichmentObj import datetime @@ -564,15 +564,32 @@ def model_post_init(self, __context: Any) -> None: # Derive TestGroups and IntegrationTests, adjust for ManualTests, skip as needed self.adjust_tests_and_groups() - #Add the default drilldown - #self.drilldown_searches.append(Drilldown.constructDrilldownFromDetection(self)) - + # Ensure that if there is at least 1 drilldown, at least + # 1 of the drilldowns contains the string Drilldown.SEARCH_PLACEHOLDER. + # This is presently a requirement when 1 or more drilldowns are added to a detection. + # Note that this is only required for production searches that are not hunting + + if self.type == AnalyticsType.Hunting.value or self.status != DetectionStatus.production.value: + #No additional check need to happen on the potential drilldowns. + pass + else: + found_placeholder = False + if len(self.drilldown_searches) < 2: + raise ValueError(f"This detection is required to have 2 drilldown_searches, but only has [{len(self.drilldown_searches)}]") + for drilldown in self.drilldown_searches: + if DRILLDOWN_SEARCH_PLACEHOLDER in drilldown.search: + found_placeholder = True + if not found_placeholder: + raise ValueError("Detection has one or more drilldown_searches, but none of them " + f"contained '{DRILLDOWN_SEARCH_PLACEHOLDER}. This is a requirement " + "if drilldown_searches are defined.'") + # Update the search fields with the original search, if required for drilldown in self.drilldown_searches: drilldown.perform_search_substitutions(self) - + #For experimental purposes, add the default drilldowns - self.drilldown_searches.extend(Drilldown.constructDrilldownsFromDetection(self)) + #self.drilldown_searches.extend(Drilldown.constructDrilldownsFromDetection(self)) @property def drilldowns_in_JSON(self) -> list[dict[str,str]]: diff --git a/contentctl/objects/drilldown.py b/contentctl/objects/drilldown.py index 4b1fa49b..3fe41e7c 100644 --- a/contentctl/objects/drilldown.py +++ b/contentctl/objects/drilldown.py @@ -4,7 +4,7 @@ if TYPE_CHECKING: from contentctl.objects.detection import Detection from contentctl.objects.enums import AnalyticsType -SEARCH_PLACEHOLDER = "%original_detection_search%" +DRILLDOWN_SEARCH_PLACEHOLDER = "%original_detection_search%" EARLIEST_OFFSET = "$info_min_time$" LATEST_OFFSET = "$info_max_time$" RISK_SEARCH = "index = risk starthoursago = 168 endhoursago = 0 | stats count values(search_name) values(risk_message) values(analyticstories) values(annotations._all) values(annotations.mitre_attack.mitre_tactic) " @@ -29,7 +29,7 @@ def constructDrilldownsFromDetection(cls, detection: Detection) -> list[Drilldow if len(victim_observables) == 0 or detection.type == AnalyticsType.Hunting: # No victims, so no drilldowns return [] - print("Adding default drilldowns. REMOVE THIS BEFORE MERGING") + print(f"Adding default drilldowns for [{detection.name}]") variableNamesString = ' and '.join([f"${o.name}$" for o in victim_observables]) nameField = f"View the detection results for {variableNamesString}" appendedSearch = " | search " + ' '.join([f"{o.name} = ${o.name}$" for o in victim_observables]) @@ -40,18 +40,20 @@ def constructDrilldownsFromDetection(cls, detection: Detection) -> list[Drilldow nameField = f"View risk events for the last 7 days for {variableNamesString}" fieldNamesListString = ', '.join([o.name for o in victim_observables]) search_field = f"{RISK_SEARCH}by {fieldNamesListString} {appendedSearch}" - #risk_events_last_7_days = cls(name=nameField, earliest_offset=EARLIEST_OFFSET, latest_offset=LATEST_OFFSET, search=search_field) risk_events_last_7_days = cls(name=nameField, earliest_offset=None, latest_offset=None, search=search_field) return [detection_results,risk_events_last_7_days] def perform_search_substitutions(self, detection:Detection)->None: - if (self.search.count("%") % 2) or (self.search.count("$") % 2): - print("\n\nWarning - a non-even number of '%' or '$' characters were found in the\n" - f"drilldown search '{self.search}' for Detection {detection.file_path}.\n" - "If this was intentional, then please ignore this warning.\n") - self.search = self.search.replace(SEARCH_PLACEHOLDER, detection.search) + """Replaces the field DRILLDOWN_SEARCH_PLACEHOLDER (%original_detection_search%) + with the search contained in the detection. We do this so that the YML does not + need the search copy/pasted from the search field into the drilldown object. + + Args: + detection (Detection): Detection to be used to update the search field of the drilldown + """ + self.search = self.search.replace(DRILLDOWN_SEARCH_PLACEHOLDER, detection.search) @model_serializer From 3a4be5d452c2b9055f9fdce0ddbd1472e5175eab Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Fri, 4 Oct 2024 16:31:35 -0700 Subject: [PATCH 14/20] Raise exception on parse of unittest from yml. Do this rather than trying to convert it into an integrationtest or manualtest. --- .../detection_abstract.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/contentctl/objects/abstract_security_content_objects/detection_abstract.py b/contentctl/objects/abstract_security_content_objects/detection_abstract.py index 1b716097..1100b72b 100644 --- a/contentctl/objects/abstract_security_content_objects/detection_abstract.py +++ b/contentctl/objects/abstract_security_content_objects/detection_abstract.py @@ -167,6 +167,7 @@ def adjust_tests_and_groups(self) -> None: the model from the list of unit tests. Also, preemptively skips all manual tests, as well as tests for experimental/deprecated detections and Correlation type detections. """ + # Since ManualTest and UnitTest are not differentiable without looking at the manual_test # tag, Pydantic builds all tests as UnitTest objects. If we see the manual_test flag, we # convert these to ManualTest @@ -789,6 +790,45 @@ def search_observables_exist_validate(self): # Found everything return self + @field_validator("tests", mode="before") + def ensure_yml_test_is_unittest(cls, v:list[dict]): + """The typing for the tests field allows it to be one of + a number of different types of tests. However, ONLY + UnitTest should be allowed to be defined in the YML + file. If part of the UnitTest defined in the YML + is incorrect, such as the attack_data file, then + it will FAIL to be instantiated as a UnitTest and + may instead be instantiated as a different type of + test, such as IntegrationTest (since that requires + less fields) which is incorrect. Ensure that any + raw data read from the YML can actually construct + a valid UnitTest and, if not, return errors right + away instead of letting Pydantic try to construct + it into a different type of test + + Args: + v (list[dict]): list of dicts read from the yml. + Each one SHOULD be a valid UnitTest. If we cannot + construct a valid unitTest from it, a ValueError should be raised + + Returns: + _type_: The input of the function, assuming no + ValueError is raised. + """ + valueErrors:list[ValueError] = [] + for unitTest in v: + #This raises a ValueError on a failed UnitTest. + try: + UnitTest.model_validate(unitTest) + except ValueError as e: + valueErrors.append(e) + if len(valueErrors): + raise ValueError(valueErrors) + # All of these can be constructred as UnitTests with no + # Exceptions, so let the normal flow continue + return v + + @field_validator("tests") def tests_validate( cls, From c627d2e75b494c802bdd30db7b803798eaad6d29 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Fri, 4 Oct 2024 18:23:37 -0700 Subject: [PATCH 15/20] In rare cases, if there is a new piece of content that has already been committed to the current branch AND there are local, uncommitted changes to that content, GitService will pick up BOTH the fact that this is new content AND the fact that it has been modified. This will result in double-testing the content. This commit removes that as a possibility by adding content to be tested to a SET instead of appending it to a LIST, which couild have included duplicates. --- .../actions/detection_testing/GitService.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/contentctl/actions/detection_testing/GitService.py b/contentctl/actions/detection_testing/GitService.py index bfed85a3..fe1f4ca8 100644 --- a/contentctl/actions/detection_testing/GitService.py +++ b/contentctl/actions/detection_testing/GitService.py @@ -67,9 +67,9 @@ def getChanges(self, target_branch:str)->List[Detection]: #Make a filename to content map filepath_to_content_map = { obj.file_path:obj for (_,obj) in self.director.name_to_content_map.items()} - updated_detections:List[Detection] = [] - updated_macros:List[Macro] = [] - updated_lookups:List[Lookup] =[] + updated_detections:set[Detection] = set() + updated_macros:set[Macro] = set() + updated_lookups:set[Lookup] = set() for diff in all_diffs: if type(diff) == pygit2.Patch: @@ -80,14 +80,14 @@ def getChanges(self, target_branch:str)->List[Detection]: if decoded_path.is_relative_to(self.config.path/"detections") and decoded_path.suffix == ".yml": detectionObject = filepath_to_content_map.get(decoded_path, None) if isinstance(detectionObject, Detection): - updated_detections.append(detectionObject) + updated_detections.add(detectionObject) else: raise Exception(f"Error getting detection object for file {str(decoded_path)}") elif decoded_path.is_relative_to(self.config.path/"macros") and decoded_path.suffix == ".yml": macroObject = filepath_to_content_map.get(decoded_path, None) if isinstance(macroObject, Macro): - updated_macros.append(macroObject) + updated_macros.add(macroObject) else: raise Exception(f"Error getting macro object for file {str(decoded_path)}") @@ -98,7 +98,7 @@ def getChanges(self, target_branch:str)->List[Detection]: updatedLookup = filepath_to_content_map.get(decoded_path, None) if not isinstance(updatedLookup,Lookup): raise Exception(f"Expected {decoded_path} to be type {type(Lookup)}, but instead if was {(type(updatedLookup))}") - updated_lookups.append(updatedLookup) + updated_lookups.add(updatedLookup) elif decoded_path.suffix == ".csv": # If the CSV was updated, we want to make sure that we @@ -125,7 +125,7 @@ def getChanges(self, target_branch:str)->List[Detection]: if updatedLookup is not None and updatedLookup not in updated_lookups: # It is possible that both the CSV and YML have been modified for the same lookup, # and we do not want to add it twice. - updated_lookups.append(updatedLookup) + updated_lookups.add(updatedLookup) else: pass @@ -136,7 +136,7 @@ def getChanges(self, target_branch:str)->List[Detection]: # If a detection has at least one dependency on changed content, # then we must test it again - changed_macros_and_lookups = updated_macros + updated_lookups + changed_macros_and_lookups:set[SecurityContentObject] = updated_macros.union(updated_lookups) for detection in self.director.detections: if detection in updated_detections: @@ -146,14 +146,14 @@ def getChanges(self, target_branch:str)->List[Detection]: for obj in changed_macros_and_lookups: if obj in detection.get_content_dependencies(): - updated_detections.append(detection) + updated_detections.add(detection) break #Print out the names of all modified/new content modifiedAndNewContentString = "\n - ".join(sorted([d.name for d in updated_detections])) print(f"[{len(updated_detections)}] Pieces of modifed and new content (this may include experimental/deprecated/manual_test content):\n - {modifiedAndNewContentString}") - return updated_detections + return sorted(list(updated_detections)) def getSelected(self, detectionFilenames: List[FilePath]) -> List[Detection]: filepath_to_content_map: dict[FilePath, SecurityContentObject] = { From 32690dff2368ea27755eded3560306ef2e4c35c4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Oct 2024 05:24:00 +0000 Subject: [PATCH 16/20] Update xmltodict requirement from ^0.13.0 to >=0.13,<0.15 Updates the requirements on [xmltodict](https://github.com/martinblech/xmltodict) to permit the latest version. - [Changelog](https://github.com/martinblech/xmltodict/blob/master/CHANGELOG.md) - [Commits](https://github.com/martinblech/xmltodict/compare/v0.13.0...v0.14.0) --- updated-dependencies: - dependency-name: xmltodict 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 1db114c9..5af34190 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ pydantic = "^2.8.2" PyYAML = "^6.0.2" requests = "~2.32.3" pycvesearch = "^1.2" -xmltodict = "^0.13.0" +xmltodict = ">=0.13,<0.15" attackcti = "^0.4.0" Jinja2 = "^3.1.4" questionary = "^2.0.1" From b12383eac23f65abe12121280bb50171acdd028d Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Wed, 9 Oct 2024 12:11:54 -0700 Subject: [PATCH 17/20] commit simple changes so that we can get this feature working now. Eventually the more robust changes will be merged from a separate branch --- contentctl/actions/inspect.py | 10 ++++++---- contentctl/objects/config.py | 18 +++++++++++------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/contentctl/actions/inspect.py b/contentctl/actions/inspect.py index 38bc2b23..261fd413 100644 --- a/contentctl/actions/inspect.py +++ b/contentctl/actions/inspect.py @@ -297,9 +297,11 @@ def check_detection_metadata(self, config: inspect) -> None: validation_errors[rule_name] = [] # No detections should be removed from build to build if rule_name not in current_build_conf.detection_stanzas: - validation_errors[rule_name].append(DetectionMissingError(rule_name=rule_name)) + if config.suppress_missing_content_exceptions: + print(f"[SUPPRESSED] {DetectionMissingError(rule_name=rule_name).long_message}") + else: + validation_errors[rule_name].append(DetectionMissingError(rule_name=rule_name)) continue - # Pull out the individual stanza for readability previous_stanza = previous_build_conf.detection_stanzas[rule_name] current_stanza = current_build_conf.detection_stanzas[rule_name] @@ -335,7 +337,7 @@ def check_detection_metadata(self, config: inspect) -> None: ) # Convert our dict mapping to a flat list of errors for use in reporting - validation_error_list = [x for inner_list in validation_errors.values() for x in inner_list] + validation_error_list = [x for inner_list in validation_errors.values() for x in inner_list] # Report failure/success print("\nDetection Metadata Validation:") @@ -355,4 +357,4 @@ def check_detection_metadata(self, config: inspect) -> None: raise ExceptionGroup( "Validation errors when comparing detection stanzas in current and previous build:", validation_error_list - ) + ) \ No newline at end of file diff --git a/contentctl/objects/config.py b/contentctl/objects/config.py index 0b262c55..f287d010 100644 --- a/contentctl/objects/config.py +++ b/contentctl/objects/config.py @@ -159,8 +159,6 @@ def getApp(self, config:test, stage_file=True)->str: verbose_print=True) return str(destination) - - # TODO (#266): disable the use_enum_values configuration class Config_Base(BaseModel): model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True) @@ -288,7 +286,6 @@ def getAPIPath(self)->pathlib.Path: def getAppTemplatePath(self)->pathlib.Path: return self.path/"app_template" - class StackType(StrEnum): @@ -311,6 +308,16 @@ class inspect(build): "should be enabled." ) ) + suppress_missing_content_exceptions: bool = Field( + default=False, + description=( + "Suppress exceptions during metadata validation if a detection that existed in " + "the previous build does not exist in this build. This is to ensure that content " + "is not accidentally removed. In order to support testing both public and private " + "content, this warning can be suppressed. If it is suppressed, it will still be " + "printed out as a warning." + ) + ) enrichments: bool = Field( default=True, description=( @@ -952,7 +959,6 @@ def check_environment_variable_for_config(cls, v:List[Infrastructure]): index+=1 - class release_notes(Config_Base): old_tag:Optional[str] = Field(None, description="Name of the tag to diff against to find new content. " "If it is not supplied, then it will be inferred as the " @@ -1034,6 +1040,4 @@ def ensureNewTagOrLatestBranch(self): # raise ValueError("The latest_branch '{self.latest_branch}' was not found in the repository") - # return self - - + # return self \ No newline at end of file From d79a0a44811c8a81e3a2774c921330ec1de2b6c6 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Wed, 9 Oct 2024 17:20:32 -0700 Subject: [PATCH 18/20] Improve logic for regex and macro detection. Throw an error when four or more ```` appear in a row in the search field, which is invalid SPL. --- contentctl/objects/macro.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/contentctl/objects/macro.py b/contentctl/objects/macro.py index 48daf602..ba5faa8f 100644 --- a/contentctl/objects/macro.py +++ b/contentctl/objects/macro.py @@ -10,7 +10,6 @@ from contentctl.input.director import DirectorOutputDto from contentctl.objects.security_content_object import SecurityContentObject - #The following macros are included in commonly-installed apps. #As such, we will ignore if they are missing from our app. #Included in @@ -55,10 +54,15 @@ def get_macros(text_field:str, director:DirectorOutputDto , ignore_macros:set[st #If a comment ENDS in a macro, for example ```this is a comment with a macro `macro_here```` #then there is a small edge case where the regex below does not work properly. If that is #the case, we edit the search slightly to insert a space - text_field = re.sub(r"\`\`\`\`", r"` ```", text_field) - text_field = re.sub(r"\`\`\`.*?\`\`\`", " ", text_field) - + if re.findall(r"\`\`\`\`", text_field): + raise ValueError("Search contained four or more '`' characters in a row which is invalid SPL" + "This may have occurred when a macro was commented out.\n" + "Please ammend your search to remove the substring '````'") + # replace all the macros with a space + text_field = re.sub(r"\`\`\`[\s\S]*?\`\`\`", " ", text_field) + + macros_to_get = re.findall(r'`([^\s]+)`', text_field) #If macros take arguments, stop at the first argument. We just want the name of the macro macros_to_get = set([macro[:macro.find('(')] if macro.find('(') != -1 else macro for macro in macros_to_get]) @@ -68,4 +72,3 @@ def get_macros(text_field:str, director:DirectorOutputDto , ignore_macros:set[st macros_to_get -= macros_to_ignore return Macro.mapNamesToSecurityContentObjects(list(macros_to_get), director) - \ No newline at end of file From 31b4b21fcc5df3f2107f5aeba3671c13c33ec0e0 Mon Sep 17 00:00:00 2001 From: Casey McGinley Date: Tue, 15 Oct 2024 14:05:27 -0700 Subject: [PATCH 19/20] refactoring for formatting and some logical error correction --- .../DetectionTestingInfrastructure.py | 8 +- .../views/DetectionTestingViewWeb.py | 15 +- contentctl/enrichments/cve_enrichment.py | 7 +- contentctl/objects/base_test_result.py | 9 +- contentctl/objects/correlation_search.py | 137 +++++++++++------- contentctl/objects/notable_event.py | 6 +- contentctl/objects/risk_analysis_action.py | 12 +- contentctl/objects/risk_event.py | 13 +- 8 files changed, 128 insertions(+), 79 deletions(-) diff --git a/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py b/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py index 4bd7e976..798f3ded 100644 --- a/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +++ b/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py @@ -48,7 +48,9 @@ class SetupTestGroupResults(BaseModel): success: bool = True duration: float = 0 start_time: float - model_config = ConfigDict(arbitrary_types_allowed=True) + model_config = ConfigDict( + arbitrary_types_allowed=True + ) class CleanupTestGroupResults(BaseModel): @@ -89,7 +91,9 @@ class DetectionTestingInfrastructure(BaseModel, abc.ABC): _conn: client.Service = PrivateAttr() pbar: tqdm.tqdm = None start_time: Optional[float] = None - model_config = ConfigDict(arbitrary_types_allowed=True) + model_config = ConfigDict( + arbitrary_types_allowed=True + ) def __init__(self, **data): super().__init__(**data) diff --git a/contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py b/contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py index 3cd8b1ed..cd50d978 100644 --- a/contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py +++ b/contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py @@ -1,14 +1,15 @@ -from bottle import template, Bottle, ServerAdapter -from contentctl.actions.detection_testing.views.DetectionTestingView import ( - DetectionTestingView, -) +from threading import Thread +from bottle import template, Bottle, ServerAdapter from wsgiref.simple_server import make_server, WSGIRequestHandler import jinja2 import webbrowser -from threading import Thread from pydantic import ConfigDict +from contentctl.actions.detection_testing.views.DetectionTestingView import ( + DetectionTestingView, +) + DEFAULT_WEB_UI_PORT = 7999 STATUS_TEMPLATE = """ @@ -101,7 +102,9 @@ def log_exception(*args, **kwargs): class DetectionTestingViewWeb(DetectionTestingView): bottleApp: Bottle = Bottle() server: SimpleWebServer = SimpleWebServer(host="0.0.0.0", port=DEFAULT_WEB_UI_PORT) - model_config = ConfigDict(arbitrary_types_allowed=True) + model_config = ConfigDict( + arbitrary_types_allowed=True + ) def setup(self): self.bottleApp.route("/", callback=self.showStatus) diff --git a/contentctl/enrichments/cve_enrichment.py b/contentctl/enrichments/cve_enrichment.py index a86cd288..66160eda 100644 --- a/contentctl/enrichments/cve_enrichment.py +++ b/contentctl/enrichments/cve_enrichment.py @@ -32,9 +32,12 @@ def url(self)->str: class CveEnrichment(BaseModel): use_enrichment: bool = True cve_api_obj: Union[CVESearch,None] = None - + # Arbitrary_types are allowed to let us use the CVESearch Object - model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True) + model_config = ConfigDict( + arbitrary_types_allowed=True, + frozen=True + ) @staticmethod def getCveEnrichment(config:validate, timeout_seconds:int=10, force_disable_enrichment:bool=True)->CveEnrichment: diff --git a/contentctl/objects/base_test_result.py b/contentctl/objects/base_test_result.py index 36450239..d29f93cb 100644 --- a/contentctl/objects/base_test_result.py +++ b/contentctl/objects/base_test_result.py @@ -2,7 +2,7 @@ from enum import Enum from pydantic import ConfigDict, BaseModel -from splunklib.data import Record +from splunklib.data import Record # type: ignore from contentctl.helper.utils import Utils @@ -52,9 +52,12 @@ class BaseTestResult(BaseModel): # The Splunk endpoint URL sid_link: Union[None, str] = None - + # Needed to allow for embedding of Exceptions in the model - model_config = ConfigDict(validate_assignment=True, arbitrary_types_allowed=True) + model_config = ConfigDict( + validate_assignment=True, + arbitrary_types_allowed=True + ) @property def passed(self) -> bool: diff --git a/contentctl/objects/correlation_search.py b/contentctl/objects/correlation_search.py index 36e19c5c..504fdf6f 100644 --- a/contentctl/objects/correlation_search.py +++ b/contentctl/objects/correlation_search.py @@ -1,8 +1,9 @@ import logging import time import json -from typing import Union, Optional, Any +from typing import Any from enum import Enum +from functools import cached_property from pydantic import ConfigDict, BaseModel, computed_field, Field, PrivateAttr from splunklib.results import JSONResultsReader, Message # type: ignore @@ -15,7 +16,7 @@ from contentctl.objects.base_test_result import TestResultStatus from contentctl.objects.integration_test_result import IntegrationTestResult from contentctl.actions.detection_testing.progress_bar import ( - format_pbar_string, + format_pbar_string, # type: ignore TestReportingType, TestingStates ) @@ -178,12 +179,14 @@ class PbarData(BaseModel): :param fq_test_name: the fully qualifed (fq) test name (":") used for logging :param start_time: the start time used for logging """ - pbar: tqdm + pbar: tqdm # type: ignore fq_test_name: str start_time: float - + # needed to support the tqdm type - model_config = ConfigDict(arbitrary_types_allowed=True) + model_config = ConfigDict( + arbitrary_types_allowed=True + ) class CorrelationSearch(BaseModel): @@ -196,78 +199,110 @@ class CorrelationSearch(BaseModel): :param pbar_data: the encapsulated info needed for logging w/ pbar :param test_index: the index attack data is forwarded to for testing (optionally used in cleanup) """ - ## The following three fields are explicitly needed at instantiation # noqa: E266 - # the detection associated with the correlation search (e.g. "Windows Modify Registry EnableLinkedConnections") - detection: Detection + detection: Detection = Field(...) # a Service instance representing a connection to a Splunk instance - service: splunklib.Service + service: splunklib.Service = Field(...) # the encapsulated info needed for logging w/ pbar - pbar_data: PbarData - - ## The following field is optional for instantiation # noqa: E266 + pbar_data: PbarData = Field(...) # The index attack data is sent to; can be None if we are relying on the caller to do our # cleanup of this index - test_index: Optional[str] = Field(default=None, min_length=1) - - ## All remaining fields can be derived from other fields or have intentional defaults that # noqa: E266 - ## should not be changed (validators should prevent instantiating some of these fields directly # noqa: E266 - ## to prevent undefined behavior) # noqa: E266 + test_index: str | None = Field(default=None, min_length=1) # The logger to use (logs all go to a null pipe unless ENABLE_LOGGING is set to True, so as not # to conflict w/ tqdm) - logger: logging.Logger = Field(default_factory=get_logger) + logger: logging.Logger = Field(default_factory=get_logger, init=False) + + # The set of indexes to clear on cleanup + indexes_to_purge: set[str] = Field(default=set(), init=False) + + # The risk analysis adaptive response action (if defined) + _risk_analysis_action: RiskAnalysisAction | None = PrivateAttr(default=None) + + # The notable adaptive response action (if defined) + _notable_action: NotableAction | None = PrivateAttr(default=None) + + # The list of risk events found + _risk_events: list[RiskEvent] | None = PrivateAttr(default=None) + + # The list of notable events found + _notable_events: list[NotableEvent] | None = PrivateAttr(default=None) + + # Need arbitrary types to allow fields w/ types like SavedSearch; we also want to forbid + # unexpected fields + model_config = ConfigDict( + arbitrary_types_allowed=True, + extra='forbid' + ) + + def model_post_init(self, __context: Any) -> None: + super().model_post_init(__context) + + # Parse the initial values for the risk/notable actions + self._parse_risk_and_notable_actions() - # The search name (e.g. "ESCU - Windows Modify Registry EnableLinkedConnections - Rule") @computed_field - @property + @cached_property def name(self) -> str: + """ + The search name (e.g. "ESCU - Windows Modify Registry EnableLinkedConnections - Rule") + + :returns: the search name + :rtype: str + """ return f"ESCU - {self.detection.name} - Rule" - # The path to the saved search on the Splunk instance @computed_field - @property + @cached_property def splunk_path(self) -> str: + """ + The path to the saved search on the Splunk instance + + :returns: the search path + :rtype: str + """ return f"/saved/searches/{self.name}" - # A model of the saved search as provided by splunklib @computed_field - @property - def saved_search(self) -> splunklib.SavedSearch | None: + @cached_property + def saved_search(self) -> splunklib.SavedSearch: + """ + A model of the saved search as provided by splunklib + + :returns: the SavedSearch object + :rtype: :class:`splunklib.client.SavedSearch` + """ return splunklib.SavedSearch( self.service, self.splunk_path, ) - # The set of indexes to clear on cleanup - indexes_to_purge: set[str] = set() - - # The risk analysis adaptive response action (if defined) + # TODO (cmcginley): need to make this refreshable @computed_field @property def risk_analysis_action(self) -> RiskAnalysisAction | None: - if not self.saved_search.content: - return None - return CorrelationSearch._get_risk_analysis_action(self.saved_search.content) + """ + The risk analysis adaptive response action (if defined) - # The notable adaptive response action (if defined) + :returns: the RiskAnalysisAction object, if it exists + :rtype: :class:`contentctl.objects.risk_analysis_action.RiskAnalysisAction` | None + """ + return self._risk_analysis_action + + # TODO (cmcginley): need to make this refreshable @computed_field @property def notable_action(self) -> NotableAction | None: - if not self.saved_search.content: - return None - return CorrelationSearch._get_notable_action(self.saved_search.content) - - # The list of risk events found - _risk_events: Optional[list[RiskEvent]] = PrivateAttr(default=None) - - # The list of notable events found - _notable_events: Optional[list[NotableEvent]] = PrivateAttr(default=None) - model_config = ConfigDict(arbitrary_types_allowed=True, extra='forbid') + """ + The notable adaptive response action (if defined) + :returns: the NotableAction object, if it exists + :rtype: :class:`contentctl.objects.notable_action.NotableAction` | None + """ + return self._notable_action @property def earliest_time(self) -> str: @@ -327,7 +362,7 @@ def has_notable_action(self) -> bool: return self.notable_action is not None @staticmethod - def _get_risk_analysis_action(content: dict[str, Any]) -> Optional[RiskAnalysisAction]: + def _get_risk_analysis_action(content: dict[str, Any]) -> RiskAnalysisAction | None: """ Given the saved search content, parse the risk analysis action :param content: a dict of strings to values @@ -341,7 +376,7 @@ def _get_risk_analysis_action(content: dict[str, Any]) -> Optional[RiskAnalysisA return None @staticmethod - def _get_notable_action(content: dict[str, Any]) -> Optional[NotableAction]: + def _get_notable_action(content: dict[str, Any]) -> NotableAction | None: """ Given the saved search content, parse the notable action :param content: a dict of strings to values @@ -365,10 +400,6 @@ def _get_relevant_observables(observables: list[Observable]) -> list[Observable] relevant.append(observable) return relevant - # TODO (PEX-484): ideally, we could handle this and the following init w/ a call to - # model_post_init, so that all the logic is encapsulated w/in _parse_risk_and_notable_actions - # but that is a pydantic v2 feature (see the init validators for risk/notable actions): - # https://docs.pydantic.dev/latest/api/base_model/#pydantic.main.BaseModel.model_post_init def _parse_risk_and_notable_actions(self) -> None: """Parses the risk/notable metadata we care about from self.saved_search.content @@ -379,12 +410,12 @@ def _parse_risk_and_notable_actions(self) -> None: unpacked to be anything other than a singleton """ # grab risk details if present - self.risk_analysis_action = CorrelationSearch._get_risk_analysis_action( + self._risk_analysis_action = CorrelationSearch._get_risk_analysis_action( self.saved_search.content # type: ignore ) # grab notable details if present - self.notable_action = CorrelationSearch._get_notable_action(self.saved_search.content) # type: ignore + self._notable_action = CorrelationSearch._get_notable_action(self.saved_search.content) # type: ignore def refresh(self) -> None: """Refreshes the metadata in the SavedSearch entity, and re-parses the fields we care about @@ -672,7 +703,7 @@ def validate_risk_events(self) -> None: # TODO (#250): Re-enable and refactor code that validates the specific risk counts # Validate risk events in aggregate; we should have an equal amount of risk events for each # relevant observable, and the total count should match the total number of events - # individual_count: Optional[int] = None + # individual_count: int | None = None # total_count = 0 # for observable_str in observable_counts: # self.logger.debug( @@ -736,7 +767,7 @@ def test(self, max_sleep: int = TimeoutConfig.MAX_SLEEP.value, raise_on_exc: boo ) # initialize result as None - result: Optional[IntegrationTestResult] = None + result: IntegrationTestResult | None = None # keep track of time slept and number of attempts for exponential backoff (base 2) elapsed_sleep_time = 0 diff --git a/contentctl/objects/notable_event.py b/contentctl/objects/notable_event.py index 51053255..51b9715d 100644 --- a/contentctl/objects/notable_event.py +++ b/contentctl/objects/notable_event.py @@ -10,10 +10,12 @@ class NotableEvent(BaseModel): # The search ID that found that generated this risk event orig_sid: str - + # Allowing fields that aren't explicitly defined to be passed since some of the risk event's # fields vary depending on the SPL which generated them - model_config = ConfigDict(extra='allow') + model_config = ConfigDict( + extra='allow' + ) def validate_against_detection(self, detection: Detection) -> None: raise NotImplementedError() diff --git a/contentctl/objects/risk_analysis_action.py b/contentctl/objects/risk_analysis_action.py index b5123236..2fa295e4 100644 --- a/contentctl/objects/risk_analysis_action.py +++ b/contentctl/objects/risk_analysis_action.py @@ -23,30 +23,30 @@ class RiskAnalysisAction(BaseModel): @field_validator("message", mode="before") @classmethod - def _validate_message(cls, message) -> str: + def _validate_message(cls, v: Any) -> str: """ - Validate splunk_path and derive if None + Validate message and derive if None """ - if message is None: + if v is None: raise ValueError( "RiskAnalysisAction.message is a required field, cannot be None. Check the " "detection YAML definition to ensure a message is defined" ) - if not isinstance(message, str): + if not isinstance(v, str): raise ValueError( "RiskAnalysisAction.message must be a string. Check the detection YAML definition " "to ensure message is defined as a string" ) - if len(message.strip()) < 1: + if len(v.strip()) < 1: raise ValueError( "RiskAnalysisAction.message must be a meaningful string, with a length greater than" "or equal to 1 (once stripped of trailing/leading whitespace). Check the detection " "YAML definition to ensure message is defined as a meanigful string" ) - return message + return v @classmethod def parse_from_dict(cls, dict_: dict[str, Any]) -> "RiskAnalysisAction": diff --git a/contentctl/objects/risk_event.py b/contentctl/objects/risk_event.py index a4443d33..de98bd0b 100644 --- a/contentctl/objects/risk_event.py +++ b/contentctl/objects/risk_event.py @@ -1,6 +1,7 @@ import re +from functools import cached_property -from pydantic import ConfigDict, BaseModel, Field, PrivateAttr, field_validator +from pydantic import ConfigDict, BaseModel, Field, PrivateAttr, field_validator, computed_field from contentctl.objects.errors import ValidationFailed from contentctl.objects.detection import Detection from contentctl.objects.observable import Observable @@ -83,11 +84,12 @@ class RiskEvent(BaseModel): # Private attribute caching the observable this RiskEvent is mapped to _matched_observable: Observable | None = PrivateAttr(default=None) - + # Allowing fields that aren't explicitly defined to be passed since some of the risk event's # fields vary depending on the SPL which generated them - model_config = ConfigDict(extra="allow") - + model_config = ConfigDict( + extra="allow" + ) @field_validator("annotations_mitre_attack", "analyticstories", mode="before") @classmethod @@ -101,7 +103,8 @@ def _convert_str_value_to_singleton(cls, v: str | list[str]) -> list[str]: else: return [v] - @property + @computed_field + @cached_property def source_field_name(self) -> str: """ A cached derivation of the source field name the risk event corresponds to in the relevant From fca535b5ff822a97223279f1a365e61c9583bc17 Mon Sep 17 00:00:00 2001 From: pyth0n1c Date: Tue, 15 Oct 2024 19:22:25 -0400 Subject: [PATCH 20/20] add drilldowns to default search included on contentctl init --- .../detections/endpoint/anomalous_usage_of_7zip.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/contentctl/templates/detections/endpoint/anomalous_usage_of_7zip.yml b/contentctl/templates/detections/endpoint/anomalous_usage_of_7zip.yml index 1a4af7b1..a101fd7d 100644 --- a/contentctl/templates/detections/endpoint/anomalous_usage_of_7zip.yml +++ b/contentctl/templates/detections/endpoint/anomalous_usage_of_7zip.yml @@ -29,6 +29,15 @@ references: - https://attack.mitre.org/techniques/T1560/001/ - https://www.microsoft.com/security/blog/2021/01/20/deep-dive-into-the-solorigate-second-stage-activation-from-sunburst-to-teardrop-and-raindrop/ - https://thedfirreport.com/2021/01/31/bazar-no-ryuk/ +drilldown_searches: +- name: View the detection results for $user$ and $dest$ + search: '%original_detection_search% | search user = $user$ dest = $dest$' + earliest_offset: $info_min_time$ + latest_offset: $info_max_time$ +- name: View risk events for the last 7 days for $user$ and $dest$ + search: '| from datamodel Risk.All_Risk | search normalized_risk_object IN ($user$, $dest$) starthoursago=168 endhoursago=1 | stats count min(_time) as firstTime max(_time) as lastTime values(search_name) as "Search Name" values(risk_message) as "Risk Message" values(analyticstories) as "Analytic Stories" values(annotations._all) as "Annotations" values(annotations.mitre_attack.mitre_tactic) as "ATT&CK Tactics" by normalized_risk_object | `security_content_ctime(firstTime)` | `security_content_ctime(lastTime)`' + earliest_offset: $info_min_time$ + latest_offset: $info_max_time$ tags: analytic_story: - Cobalt Strike @@ -80,4 +89,4 @@ tests: attack_data: - data: https://media.githubusercontent.com/media/splunk/attack_data/master/datasets/attack_techniques/T1560.001/archive_utility/windows-sysmon.log source: XmlWinEventLog:Microsoft-Windows-Sysmon/Operational - sourcetype: xmlwineventlog \ No newline at end of file + sourcetype: xmlwineventlog