Skip to content

Commit 9830993

Browse files
authored
Merge branch 'main' into python313
2 parents 3ba8a09 + f7a939b commit 9830993

File tree

18 files changed

+329
-191
lines changed

18 files changed

+329
-191
lines changed

contentctl/actions/detection_testing/GitService.py

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -67,9 +67,9 @@ def getChanges(self, target_branch:str)->List[Detection]:
6767

6868
#Make a filename to content map
6969
filepath_to_content_map = { obj.file_path:obj for (_,obj) in self.director.name_to_content_map.items()}
70-
updated_detections:List[Detection] = []
71-
updated_macros:List[Macro] = []
72-
updated_lookups:List[Lookup] =[]
70+
updated_detections:set[Detection] = set()
71+
updated_macros:set[Macro] = set()
72+
updated_lookups:set[Lookup] = set()
7373

7474
for diff in all_diffs:
7575
if type(diff) == pygit2.Patch:
@@ -80,14 +80,14 @@ def getChanges(self, target_branch:str)->List[Detection]:
8080
if decoded_path.is_relative_to(self.config.path/"detections") and decoded_path.suffix == ".yml":
8181
detectionObject = filepath_to_content_map.get(decoded_path, None)
8282
if isinstance(detectionObject, Detection):
83-
updated_detections.append(detectionObject)
83+
updated_detections.add(detectionObject)
8484
else:
8585
raise Exception(f"Error getting detection object for file {str(decoded_path)}")
8686

8787
elif decoded_path.is_relative_to(self.config.path/"macros") and decoded_path.suffix == ".yml":
8888
macroObject = filepath_to_content_map.get(decoded_path, None)
8989
if isinstance(macroObject, Macro):
90-
updated_macros.append(macroObject)
90+
updated_macros.add(macroObject)
9191
else:
9292
raise Exception(f"Error getting macro object for file {str(decoded_path)}")
9393

@@ -98,7 +98,7 @@ def getChanges(self, target_branch:str)->List[Detection]:
9898
updatedLookup = filepath_to_content_map.get(decoded_path, None)
9999
if not isinstance(updatedLookup,Lookup):
100100
raise Exception(f"Expected {decoded_path} to be type {type(Lookup)}, but instead if was {(type(updatedLookup))}")
101-
updated_lookups.append(updatedLookup)
101+
updated_lookups.add(updatedLookup)
102102

103103
elif decoded_path.suffix == ".csv":
104104
# If the CSV was updated, we want to make sure that we
@@ -125,7 +125,7 @@ def getChanges(self, target_branch:str)->List[Detection]:
125125
if updatedLookup is not None and updatedLookup not in updated_lookups:
126126
# It is possible that both the CSV and YML have been modified for the same lookup,
127127
# and we do not want to add it twice.
128-
updated_lookups.append(updatedLookup)
128+
updated_lookups.add(updatedLookup)
129129

130130
else:
131131
pass
@@ -136,7 +136,7 @@ def getChanges(self, target_branch:str)->List[Detection]:
136136

137137
# If a detection has at least one dependency on changed content,
138138
# then we must test it again
139-
changed_macros_and_lookups = updated_macros + updated_lookups
139+
changed_macros_and_lookups:set[SecurityContentObject] = updated_macros.union(updated_lookups)
140140

141141
for detection in self.director.detections:
142142
if detection in updated_detections:
@@ -146,14 +146,14 @@ def getChanges(self, target_branch:str)->List[Detection]:
146146

147147
for obj in changed_macros_and_lookups:
148148
if obj in detection.get_content_dependencies():
149-
updated_detections.append(detection)
149+
updated_detections.add(detection)
150150
break
151151

152152
#Print out the names of all modified/new content
153153
modifiedAndNewContentString = "\n - ".join(sorted([d.name for d in updated_detections]))
154154

155155
print(f"[{len(updated_detections)}] Pieces of modifed and new content (this may include experimental/deprecated/manual_test content):\n - {modifiedAndNewContentString}")
156-
return updated_detections
156+
return sorted(list(updated_detections))
157157

158158
def getSelected(self, detectionFilenames: List[FilePath]) -> List[Detection]:
159159
filepath_to_content_map: dict[FilePath, SecurityContentObject] = {

contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from shutil import copyfile
1414
from typing import Union, Optional
1515

16-
from pydantic import BaseModel, PrivateAttr, Field, dataclasses
16+
from pydantic import ConfigDict, BaseModel, PrivateAttr, Field, dataclasses
1717
import requests # type: ignore
1818
import splunklib.client as client # type: ignore
1919
from splunklib.binding import HTTPError # type: ignore
@@ -48,9 +48,9 @@ class SetupTestGroupResults(BaseModel):
4848
success: bool = True
4949
duration: float = 0
5050
start_time: float
51-
52-
class Config:
53-
arbitrary_types_allowed = True
51+
model_config = ConfigDict(
52+
arbitrary_types_allowed=True
53+
)
5454

5555

5656
class CleanupTestGroupResults(BaseModel):
@@ -91,9 +91,9 @@ class DetectionTestingInfrastructure(BaseModel, abc.ABC):
9191
_conn: client.Service = PrivateAttr()
9292
pbar: tqdm.tqdm = None
9393
start_time: Optional[float] = None
94-
95-
class Config:
96-
arbitrary_types_allowed = True
94+
model_config = ConfigDict(
95+
arbitrary_types_allowed=True
96+
)
9797

9898
def __init__(self, **data):
9999
super().__init__(**data)

contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1-
from bottle import template, Bottle, ServerAdapter
2-
from contentctl.actions.detection_testing.views.DetectionTestingView import (
3-
DetectionTestingView,
4-
)
1+
from threading import Thread
52

3+
from bottle import template, Bottle, ServerAdapter
64
from wsgiref.simple_server import make_server, WSGIRequestHandler
75
import jinja2
86
import webbrowser
9-
from threading import Thread
7+
from pydantic import ConfigDict
8+
9+
from contentctl.actions.detection_testing.views.DetectionTestingView import (
10+
DetectionTestingView,
11+
)
1012

1113
DEFAULT_WEB_UI_PORT = 7999
1214

@@ -100,9 +102,9 @@ def log_exception(*args, **kwargs):
100102
class DetectionTestingViewWeb(DetectionTestingView):
101103
bottleApp: Bottle = Bottle()
102104
server: SimpleWebServer = SimpleWebServer(host="0.0.0.0", port=DEFAULT_WEB_UI_PORT)
103-
104-
class Config:
105-
arbitrary_types_allowed = True
105+
model_config = ConfigDict(
106+
arbitrary_types_allowed=True
107+
)
106108

107109
def setup(self):
108110
self.bottleApp.route("/", callback=self.showStatus)

contentctl/actions/inspect.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -297,9 +297,11 @@ def check_detection_metadata(self, config: inspect) -> None:
297297
validation_errors[rule_name] = []
298298
# No detections should be removed from build to build
299299
if rule_name not in current_build_conf.detection_stanzas:
300-
validation_errors[rule_name].append(DetectionMissingError(rule_name=rule_name))
300+
if config.suppress_missing_content_exceptions:
301+
print(f"[SUPPRESSED] {DetectionMissingError(rule_name=rule_name).long_message}")
302+
else:
303+
validation_errors[rule_name].append(DetectionMissingError(rule_name=rule_name))
301304
continue
302-
303305
# Pull out the individual stanza for readability
304306
previous_stanza = previous_build_conf.detection_stanzas[rule_name]
305307
current_stanza = current_build_conf.detection_stanzas[rule_name]
@@ -335,7 +337,7 @@ def check_detection_metadata(self, config: inspect) -> None:
335337
)
336338

337339
# Convert our dict mapping to a flat list of errors for use in reporting
338-
validation_error_list = [x for inner_list in validation_errors.values() for x in inner_list]
340+
validation_error_list = [x for inner_list in validation_errors.values() for x in inner_list]
339341

340342
# Report failure/success
341343
print("\nDetection Metadata Validation:")
@@ -355,4 +357,4 @@ def check_detection_metadata(self, config: inspect) -> None:
355357
raise ExceptionGroup(
356358
"Validation errors when comparing detection stanzas in current and previous build:",
357359
validation_error_list
358-
)
360+
)

contentctl/enrichments/cve_enrichment.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import shelve
66
import time
77
from typing import Annotated, Any, Union, TYPE_CHECKING
8-
from pydantic import BaseModel,Field, computed_field
8+
from pydantic import ConfigDict, BaseModel,Field, computed_field
99
from decimal import Decimal
1010
from requests.exceptions import ReadTimeout
1111
from contentctl.objects.annotated_types import CVE_TYPE
@@ -32,13 +32,12 @@ def url(self)->str:
3232
class CveEnrichment(BaseModel):
3333
use_enrichment: bool = True
3434
cve_api_obj: Union[CVESearch,None] = None
35-
3635

37-
class Config:
38-
# Arbitrary_types are allowed to let us use the CVESearch Object
39-
arbitrary_types_allowed = True
40-
frozen = True
41-
36+
# Arbitrary_types are allowed to let us use the CVESearch Object
37+
model_config = ConfigDict(
38+
arbitrary_types_allowed=True,
39+
frozen=True
40+
)
4241

4342
@staticmethod
4443
def getCveEnrichment(config:validate, timeout_seconds:int=10, force_disable_enrichment:bool=True)->CveEnrichment:

contentctl/objects/abstract_security_content_objects/detection_abstract.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from contentctl.objects.integration_test import IntegrationTest
3737
from contentctl.objects.data_source import DataSource
3838
from contentctl.objects.base_test_result import TestResultStatus
39-
39+
from contentctl.objects.drilldown import Drilldown, DRILLDOWN_SEARCH_PLACEHOLDER
4040
from contentctl.objects.enums import ProvidingTechnology
4141
from contentctl.enrichments.cve_enrichment import CveEnrichmentObj
4242
import datetime
@@ -90,6 +90,7 @@ class Detection_Abstract(SecurityContentObject):
9090
test_groups: list[TestGroup] = []
9191

9292
data_source_objects: list[DataSource] = []
93+
drilldown_searches: list[Drilldown] = Field(default=[], description="A list of Drilldowns that should be included with this search")
9394

9495
def get_conf_stanza_name(self, app:CustomApp)->str:
9596
stanza_name = CONTENTCTL_DETECTION_STANZA_NAME_FORMAT_TEMPLATE.format(app_label=app.label, detection_name=self.name)
@@ -167,6 +168,7 @@ def adjust_tests_and_groups(self) -> None:
167168
the model from the list of unit tests. Also, preemptively skips all manual tests, as well as
168169
tests for experimental/deprecated detections and Correlation type detections.
169170
"""
171+
170172
# Since ManualTest and UnitTest are not differentiable without looking at the manual_test
171173
# tag, Pydantic builds all tests as UnitTest objects. If we see the manual_test flag, we
172174
# convert these to ManualTest
@@ -563,6 +565,46 @@ def model_post_init(self, __context: Any) -> None:
563565
# Derive TestGroups and IntegrationTests, adjust for ManualTests, skip as needed
564566
self.adjust_tests_and_groups()
565567

568+
# Ensure that if there is at least 1 drilldown, at least
569+
# 1 of the drilldowns contains the string Drilldown.SEARCH_PLACEHOLDER.
570+
# This is presently a requirement when 1 or more drilldowns are added to a detection.
571+
# Note that this is only required for production searches that are not hunting
572+
573+
if self.type == AnalyticsType.Hunting.value or self.status != DetectionStatus.production.value:
574+
#No additional check need to happen on the potential drilldowns.
575+
pass
576+
else:
577+
found_placeholder = False
578+
if len(self.drilldown_searches) < 2:
579+
raise ValueError(f"This detection is required to have 2 drilldown_searches, but only has [{len(self.drilldown_searches)}]")
580+
for drilldown in self.drilldown_searches:
581+
if DRILLDOWN_SEARCH_PLACEHOLDER in drilldown.search:
582+
found_placeholder = True
583+
if not found_placeholder:
584+
raise ValueError("Detection has one or more drilldown_searches, but none of them "
585+
f"contained '{DRILLDOWN_SEARCH_PLACEHOLDER}. This is a requirement "
586+
"if drilldown_searches are defined.'")
587+
588+
# Update the search fields with the original search, if required
589+
for drilldown in self.drilldown_searches:
590+
drilldown.perform_search_substitutions(self)
591+
592+
#For experimental purposes, add the default drilldowns
593+
#self.drilldown_searches.extend(Drilldown.constructDrilldownsFromDetection(self))
594+
595+
@property
596+
def drilldowns_in_JSON(self) -> list[dict[str,str]]:
597+
"""This function is required for proper JSON
598+
serializiation of drilldowns to occur in savedsearches.conf.
599+
It returns the list[Drilldown] as a list[dict].
600+
Without this function, the jinja template is unable
601+
to convert list[Drilldown] to JSON
602+
603+
Returns:
604+
list[dict[str,str]]: List of Drilldowns dumped to dict format
605+
"""
606+
return [drilldown.model_dump() for drilldown in self.drilldown_searches]
607+
566608
@field_validator('lookups', mode="before")
567609
@classmethod
568610
def getDetectionLookups(cls, v:list[str], info:ValidationInfo) -> list[Lookup]:
@@ -789,6 +831,45 @@ def search_observables_exist_validate(self):
789831
# Found everything
790832
return self
791833

834+
@field_validator("tests", mode="before")
835+
def ensure_yml_test_is_unittest(cls, v:list[dict]):
836+
"""The typing for the tests field allows it to be one of
837+
a number of different types of tests. However, ONLY
838+
UnitTest should be allowed to be defined in the YML
839+
file. If part of the UnitTest defined in the YML
840+
is incorrect, such as the attack_data file, then
841+
it will FAIL to be instantiated as a UnitTest and
842+
may instead be instantiated as a different type of
843+
test, such as IntegrationTest (since that requires
844+
less fields) which is incorrect. Ensure that any
845+
raw data read from the YML can actually construct
846+
a valid UnitTest and, if not, return errors right
847+
away instead of letting Pydantic try to construct
848+
it into a different type of test
849+
850+
Args:
851+
v (list[dict]): list of dicts read from the yml.
852+
Each one SHOULD be a valid UnitTest. If we cannot
853+
construct a valid unitTest from it, a ValueError should be raised
854+
855+
Returns:
856+
_type_: The input of the function, assuming no
857+
ValueError is raised.
858+
"""
859+
valueErrors:list[ValueError] = []
860+
for unitTest in v:
861+
#This raises a ValueError on a failed UnitTest.
862+
try:
863+
UnitTest.model_validate(unitTest)
864+
except ValueError as e:
865+
valueErrors.append(e)
866+
if len(valueErrors):
867+
raise ValueError(valueErrors)
868+
# All of these can be constructred as UnitTests with no
869+
# Exceptions, so let the normal flow continue
870+
return v
871+
872+
792873
@field_validator("tests")
793874
def tests_validate(
794875
cls,

contentctl/objects/base_test_result.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from typing import Union, Any
22
from enum import Enum
33

4-
from pydantic import BaseModel
5-
from splunklib.data import Record
4+
from pydantic import ConfigDict, BaseModel
5+
from splunklib.data import Record # type: ignore
66

77
from contentctl.helper.utils import Utils
88

@@ -53,11 +53,11 @@ class BaseTestResult(BaseModel):
5353
# The Splunk endpoint URL
5454
sid_link: Union[None, str] = None
5555

56-
class Config:
57-
validate_assignment = True
58-
59-
# Needed to allow for embedding of Exceptions in the model
60-
arbitrary_types_allowed = True
56+
# Needed to allow for embedding of Exceptions in the model
57+
model_config = ConfigDict(
58+
validate_assignment=True,
59+
arbitrary_types_allowed=True
60+
)
6161

6262
@property
6363
def passed(self) -> bool:

contentctl/objects/config.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -159,8 +159,6 @@ def getApp(self, config:test, stage_file=True)->str:
159159
verbose_print=True)
160160
return str(destination)
161161

162-
163-
164162
# TODO (#266): disable the use_enum_values configuration
165163
class Config_Base(BaseModel):
166164
model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
@@ -288,7 +286,6 @@ def getAPIPath(self)->pathlib.Path:
288286

289287
def getAppTemplatePath(self)->pathlib.Path:
290288
return self.path/"app_template"
291-
292289

293290

294291
class StackType(StrEnum):
@@ -311,6 +308,16 @@ class inspect(build):
311308
"should be enabled."
312309
)
313310
)
311+
suppress_missing_content_exceptions: bool = Field(
312+
default=False,
313+
description=(
314+
"Suppress exceptions during metadata validation if a detection that existed in "
315+
"the previous build does not exist in this build. This is to ensure that content "
316+
"is not accidentally removed. In order to support testing both public and private "
317+
"content, this warning can be suppressed. If it is suppressed, it will still be "
318+
"printed out as a warning."
319+
)
320+
)
314321
enrichments: bool = Field(
315322
default=True,
316323
description=(
@@ -952,7 +959,6 @@ def check_environment_variable_for_config(cls, v:List[Infrastructure]):
952959
index+=1
953960

954961

955-
956962
class release_notes(Config_Base):
957963
old_tag:Optional[str] = Field(None, description="Name of the tag to diff against to find new content. "
958964
"If it is not supplied, then it will be inferred as the "
@@ -1034,6 +1040,4 @@ def ensureNewTagOrLatestBranch(self):
10341040
# raise ValueError("The latest_branch '{self.latest_branch}' was not found in the repository")
10351041

10361042

1037-
# return self
1038-
1039-
1043+
# return self

0 commit comments

Comments
 (0)