Skip to content

Commit 02eb5d7

Browse files
authored
Merge pull request #300 from splunk/raise_exception_on_malformatted_tests
Exception on malformatted unit tests in YMLs
2 parents 2cc708b + 8f73477 commit 02eb5d7

File tree

3 files changed

+58
-15
lines changed

3 files changed

+58
-15
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/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/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-

0 commit comments

Comments
 (0)