Skip to content

Commit 1550aff

Browse files
authored
Merge branch 'main' into mathieugonzales_replace_deprecated_pydantic_validators
2 parents d3e063a + 02eb5d7 commit 1550aff

File tree

6 files changed

+76
-27
lines changed

6 files changed

+76
-27
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/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/objects/abstract_security_content_objects/detection_abstract.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ def adjust_tests_and_groups(self) -> None:
167167
the model from the list of unit tests. Also, preemptively skips all manual tests, as well as
168168
tests for experimental/deprecated detections and Correlation type detections.
169169
"""
170+
170171
# Since ManualTest and UnitTest are not differentiable without looking at the manual_test
171172
# tag, Pydantic builds all tests as UnitTest objects. If we see the manual_test flag, we
172173
# convert these to ManualTest
@@ -789,6 +790,45 @@ def search_observables_exist_validate(self):
789790
# Found everything
790791
return self
791792

793+
@field_validator("tests", mode="before")
794+
def ensure_yml_test_is_unittest(cls, v:list[dict]):
795+
"""The typing for the tests field allows it to be one of
796+
a number of different types of tests. However, ONLY
797+
UnitTest should be allowed to be defined in the YML
798+
file. If part of the UnitTest defined in the YML
799+
is incorrect, such as the attack_data file, then
800+
it will FAIL to be instantiated as a UnitTest and
801+
may instead be instantiated as a different type of
802+
test, such as IntegrationTest (since that requires
803+
less fields) which is incorrect. Ensure that any
804+
raw data read from the YML can actually construct
805+
a valid UnitTest and, if not, return errors right
806+
away instead of letting Pydantic try to construct
807+
it into a different type of test
808+
809+
Args:
810+
v (list[dict]): list of dicts read from the yml.
811+
Each one SHOULD be a valid UnitTest. If we cannot
812+
construct a valid unitTest from it, a ValueError should be raised
813+
814+
Returns:
815+
_type_: The input of the function, assuming no
816+
ValueError is raised.
817+
"""
818+
valueErrors:list[ValueError] = []
819+
for unitTest in v:
820+
#This raises a ValueError on a failed UnitTest.
821+
try:
822+
UnitTest.model_validate(unitTest)
823+
except ValueError as e:
824+
valueErrors.append(e)
825+
if len(valueErrors):
826+
raise ValueError(valueErrors)
827+
# All of these can be constructred as UnitTests with no
828+
# Exceptions, so let the normal flow continue
829+
return v
830+
831+
792832
@field_validator("tests")
793833
def tests_validate(
794834
cls,

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

contentctl/objects/macro.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from contentctl.input.director import DirectorOutputDto
1111
from contentctl.objects.security_content_object import SecurityContentObject
1212

13-
1413
#The following macros are included in commonly-installed apps.
1514
#As such, we will ignore if they are missing from our app.
1615
#Included in
@@ -55,10 +54,15 @@ def get_macros(text_field:str, director:DirectorOutputDto , ignore_macros:set[st
5554
#If a comment ENDS in a macro, for example ```this is a comment with a macro `macro_here````
5655
#then there is a small edge case where the regex below does not work properly. If that is
5756
#the case, we edit the search slightly to insert a space
58-
text_field = re.sub(r"\`\`\`\`", r"` ```", text_field)
59-
text_field = re.sub(r"\`\`\`.*?\`\`\`", " ", text_field)
60-
57+
if re.findall(r"\`\`\`\`", text_field):
58+
raise ValueError("Search contained four or more '`' characters in a row which is invalid SPL"
59+
"This may have occurred when a macro was commented out.\n"
60+
"Please ammend your search to remove the substring '````'")
6161

62+
# replace all the macros with a space
63+
text_field = re.sub(r"\`\`\`[\s\S]*?\`\`\`", " ", text_field)
64+
65+
6266
macros_to_get = re.findall(r'`([^\s]+)`', text_field)
6367
#If macros take arguments, stop at the first argument. We just want the name of the macro
6468
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
6872
macros_to_get -= macros_to_ignore
6973
return Macro.mapNamesToSecurityContentObjects(list(macros_to_get), director)
7074

71-

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ pydantic = "^2.8.2"
1515
PyYAML = "^6.0.2"
1616
requests = "~2.32.3"
1717
pycvesearch = "^1.2"
18-
xmltodict = "^0.13.0"
18+
xmltodict = ">=0.13,<0.15"
1919
attackcti = "^0.4.0"
2020
Jinja2 = "^3.1.4"
2121
questionary = "^2.0.1"

0 commit comments

Comments
 (0)