Skip to content

Commit 90f1b91

Browse files
Merge pull request #257 from splunk/feature/coverage-report
Expanding coverage and other metrics in summary.yml
2 parents 01d3853 + 09170dd commit 90f1b91

24 files changed

+427
-216
lines changed

contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
from tempfile import TemporaryDirectory, mktemp
1111
from ssl import SSLEOFError, SSLZeroReturnError
1212
from sys import stdout
13-
#from dataclasses import dataclass
1413
from shutil import copyfile
1514
from typing import Union, Optional
1615

@@ -29,7 +28,7 @@
2928
from contentctl.objects.base_test import BaseTest
3029
from contentctl.objects.unit_test import UnitTest
3130
from contentctl.objects.integration_test import IntegrationTest
32-
from contentctl.objects.unit_test_attack_data import UnitTestAttackData
31+
from contentctl.objects.test_attack_data import TestAttackData
3332
from contentctl.objects.unit_test_result import UnitTestResult
3433
from contentctl.objects.integration_test_result import IntegrationTestResult
3534
from contentctl.objects.test_group import TestGroup
@@ -61,13 +60,19 @@ class CleanupTestGroupResults(BaseModel):
6160

6261
class ContainerStoppedException(Exception):
6362
pass
63+
class CannotRunBaselineException(Exception):
64+
# Support for testing detections with baselines
65+
# does not currently exist in contentctl.
66+
# As such, whenever we encounter a detection
67+
# with baselines we should generate a descriptive
68+
# exception
69+
pass
6470

6571

6672
@dataclasses.dataclass(frozen=False)
6773
class DetectionTestingManagerOutputDto():
6874
inputQueue: list[Detection] = Field(default_factory=list)
6975
outputQueue: list[Detection] = Field(default_factory=list)
70-
skippedQueue: list[Detection] = Field(default_factory=list)
7176
currentTestingQueue: dict[str, Union[Detection, None]] = Field(default_factory=dict)
7277
start_time: Union[datetime.datetime, None] = None
7378
replay_index: str = "CONTENTCTL_TESTING_INDEX"
@@ -647,11 +652,7 @@ def execute_unit_test(
647652
# Set the mode and timeframe, if required
648653
kwargs = {"exec_mode": "blocking"}
649654

650-
# Iterate over baselines (if any)
651-
for baseline in test.baselines:
652-
# TODO: this is executing the test, not the baseline...
653-
# TODO: should this be in a try/except if the later call is?
654-
self.retry_search_until_timeout(detection, test, kwargs, test_start_time)
655+
655656

656657
# Set earliest_time and latest_time appropriately if FORCE_ALL_TIME is False
657658
if not FORCE_ALL_TIME:
@@ -662,7 +663,23 @@ def execute_unit_test(
662663

663664
# Run the detection's search query
664665
try:
666+
# Iterate over baselines (if any)
667+
for baseline in detection.baselines:
668+
raise CannotRunBaselineException("Detection requires Execution of a Baseline, "
669+
"however Baseline execution is not "
670+
"currently supported in contentctl. Mark "
671+
"this as manual_test.")
665672
self.retry_search_until_timeout(detection, test, kwargs, test_start_time)
673+
except CannotRunBaselineException as e:
674+
# Init the test result and record a failure if there was an issue during the search
675+
test.result = UnitTestResult()
676+
test.result.set_job_content(
677+
None,
678+
self.infrastructure,
679+
TestResultStatus.ERROR,
680+
exception=e,
681+
duration=time.time() - test_start_time
682+
)
666683
except ContainerStoppedException as e:
667684
raise e
668685
except Exception as e:
@@ -1015,18 +1032,15 @@ def retry_search_until_timeout(
10151032
"""
10161033
# Get the start time and compute the timeout
10171034
search_start_time = time.time()
1018-
search_stop_time = time.time() + self.sync_obj.timeout_seconds
1019-
1020-
# We will default to ensuring at least one result exists
1021-
if test.pass_condition is None:
1022-
search = detection.search
1023-
else:
1024-
# Else, use the explicit pass condition
1025-
search = f"{detection.search} {test.pass_condition}"
1035+
search_stop_time = time.time() + self.sync_obj.timeout_seconds
1036+
1037+
# Make a copy of the search string since we may
1038+
# need to make some small changes to it below
1039+
search = detection.search
10261040

10271041
# Ensure searches that do not begin with '|' must begin with 'search '
1028-
if not search.strip().startswith("|"): # type: ignore
1029-
if not search.strip().startswith("search "): # type: ignore
1042+
if not search.strip().startswith("|"):
1043+
if not search.strip().startswith("search "):
10301044
search = f"search {search}"
10311045

10321046
# exponential backoff for wait time
@@ -1179,7 +1193,7 @@ def retry_search_until_timeout(
11791193

11801194
return
11811195

1182-
def delete_attack_data(self, attack_data_files: list[UnitTestAttackData]):
1196+
def delete_attack_data(self, attack_data_files: list[TestAttackData]):
11831197
for attack_data_file in attack_data_files:
11841198
index = attack_data_file.custom_index or self.sync_obj.replay_index
11851199
host = attack_data_file.host or self.sync_obj.replay_host
@@ -1212,7 +1226,7 @@ def replay_attack_data_files(
12121226

12131227
def replay_attack_data_file(
12141228
self,
1215-
attack_data_file: UnitTestAttackData,
1229+
attack_data_file: TestAttackData,
12161230
tmp_dir: str,
12171231
test_group: TestGroup,
12181232
test_group_start_time: float,
@@ -1280,7 +1294,7 @@ def replay_attack_data_file(
12801294
def hec_raw_replay(
12811295
self,
12821296
tempfile: str,
1283-
attack_data_file: UnitTestAttackData,
1297+
attack_data_file: TestAttackData,
12841298
verify_ssl: bool = False,
12851299
):
12861300
if verify_ssl is False:

contentctl/actions/detection_testing/views/DetectionTestingView.py

Lines changed: 64 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import abc
22
import datetime
3+
from typing import Any
34

45
from pydantic import BaseModel
56

@@ -10,6 +11,7 @@
1011
)
1112
from contentctl.helper.utils import Utils
1213
from contentctl.objects.enums import DetectionStatus
14+
from contentctl.objects.base_test_result import TestResultStatus
1315

1416

1517
class DetectionTestingView(BaseModel, abc.ABC):
@@ -74,18 +76,23 @@ def getSummaryObject(
7476
self,
7577
test_result_fields: list[str] = ["success", "message", "exception", "status", "duration", "wait_duration"],
7678
test_job_fields: list[str] = ["resultCount", "runDuration"],
77-
) -> dict:
79+
) -> dict[str, dict[str, Any] | list[dict[str, Any]] | str]:
7880
"""
7981
Iterates over detections, consolidating results into a single dict and aggregating metrics
8082
:param test_result_fields: fields to pull from the test result
8183
:param test_job_fields: fields to pull from the job content of the test result
8284
:returns: summary dict
8385
"""
84-
# Init the list of tested detections, and some metrics aggregate counters
85-
tested_detections = []
86+
# Init the list of tested and skipped detections, and some metrics aggregate counters
87+
tested_detections: list[dict[str, Any]] = []
88+
skipped_detections: list[dict[str, Any]] = []
8689
total_pass = 0
8790
total_fail = 0
8891
total_skipped = 0
92+
total_production = 0
93+
total_experimental = 0
94+
total_deprecated = 0
95+
total_manual = 0
8996

9097
# Iterate the detections tested (anything in the output queue was tested)
9198
for detection in self.sync_obj.outputQueue:
@@ -95,46 +102,59 @@ def getSummaryObject(
95102
)
96103

97104
# Aggregate detection pass/fail metrics
98-
if summary["success"] is False:
105+
if detection.test_status == TestResultStatus.FAIL:
99106
total_fail += 1
107+
elif detection.test_status == TestResultStatus.PASS:
108+
total_pass += 1
109+
elif detection.test_status == TestResultStatus.SKIP:
110+
total_skipped += 1
111+
112+
# Aggregate production status metrics
113+
if detection.status == DetectionStatus.production.value: # type: ignore
114+
total_production += 1
115+
elif detection.status == DetectionStatus.experimental.value: # type: ignore
116+
total_experimental += 1
117+
elif detection.status == DetectionStatus.deprecated.value: # type: ignore
118+
total_deprecated += 1
119+
120+
# Check if the detection is manual_test
121+
if detection.tags.manual_test is not None:
122+
total_manual += 1
123+
124+
# Append to our list (skipped or tested)
125+
if detection.test_status == TestResultStatus.SKIP:
126+
skipped_detections.append(summary)
100127
else:
101-
#Test is marked as a success, but we need to determine if there were skipped unit tests
102-
#SKIPPED tests still show a success in this field, but we want to count them differently
103-
pass_increment = 1
104-
for test in summary.get("tests"):
105-
if test.get("test_type") == "unit" and test.get("status") == "skip":
106-
total_skipped += 1
107-
#Test should not count as a pass, so do not increment the count
108-
pass_increment = 0
109-
break
110-
total_pass += pass_increment
111-
112-
113-
# Append to our list
114-
tested_detections.append(summary)
115-
116-
# Sort s.t. all failures appear first (then by name)
117-
#Second short condition is a hack to get detections with unit skipped tests to appear above pass tests
118-
tested_detections.sort(key=lambda x: (x["success"], 0 if x.get("tests",[{}])[0].get("status","status_missing")=="skip" else 1, x["name"]))
128+
tested_detections.append(summary)
119129

130+
# Sort tested detections s.t. all failures appear first, then by name
131+
tested_detections.sort(
132+
key=lambda x: (
133+
x["success"],
134+
x["name"]
135+
)
136+
)
137+
138+
# Sort skipped detections s.t. detections w/ tests appear before those w/o, then by name
139+
skipped_detections.sort(
140+
key=lambda x: (
141+
0 if len(x["tests"]) > 0 else 1,
142+
x["name"]
143+
)
144+
)
145+
146+
# TODO (#267): Align test reporting more closely w/ status enums (as it relates to
147+
# "untested")
120148
# Aggregate summaries for the untested detections (anything still in the input queue was untested)
121149
total_untested = len(self.sync_obj.inputQueue)
122-
untested_detections = []
150+
untested_detections: list[dict[str, Any]] = []
123151
for detection in self.sync_obj.inputQueue:
124152
untested_detections.append(detection.get_summary())
125153

126154
# Sort by detection name
127155
untested_detections.sort(key=lambda x: x["name"])
128156

129-
# Get lists of detections (name only) that were skipped due to their status (experimental or deprecated)
130-
experimental_detections = sorted([
131-
detection.name for detection in self.sync_obj.skippedQueue if detection.status == DetectionStatus.experimental.value
132-
])
133-
deprecated_detections = sorted([
134-
detection.name for detection in self.sync_obj.skippedQueue if detection.status == DetectionStatus.deprecated.value
135-
])
136-
137-
# If any detection failed, the overall success is False
157+
# If any detection failed, or if there are untested detections, the overall success is False
138158
if (total_fail + len(untested_detections)) == 0:
139159
overall_success = True
140160
else:
@@ -143,33 +163,39 @@ def getSummaryObject(
143163
# Compute total detections
144164
total_detections = total_fail + total_pass + total_untested + total_skipped
145165

166+
# Compute total detections actually tested (at least one test not skipped)
167+
total_tested_detections = total_fail + total_pass
146168

147169
# Compute the percentage of completion for testing, as well as the success rate
148170
percent_complete = Utils.getPercent(
149171
len(tested_detections), len(untested_detections), 1
150172
)
151173
success_rate = Utils.getPercent(
152-
total_pass, total_detections-total_skipped, 1
174+
total_pass, total_tested_detections, 1
153175
)
154176

155-
# TODO (#230): expand testing metrics reported
177+
# TODO (#230): expand testing metrics reported (and make nested)
156178
# Construct and return the larger results dict
157179
result_dict = {
158180
"summary": {
181+
"mode": self.config.getModeName(),
182+
"enable_integration_testing": self.config.enable_integration_testing,
159183
"success": overall_success,
160184
"total_detections": total_detections,
185+
"total_tested_detections": total_tested_detections,
161186
"total_pass": total_pass,
162187
"total_fail": total_fail,
163188
"total_skipped": total_skipped,
164189
"total_untested": total_untested,
165-
"total_experimental_or_deprecated": len(deprecated_detections+experimental_detections),
190+
"total_production": total_production,
191+
"total_experimental": total_experimental,
192+
"total_deprecated": total_deprecated,
193+
"total_manual": total_manual,
166194
"success_rate": success_rate,
167195
},
168196
"tested_detections": tested_detections,
197+
"skipped_detections": skipped_detections,
169198
"untested_detections": untested_detections,
170199
"percent_complete": percent_complete,
171-
"deprecated_detections": deprecated_detections,
172-
"experimental_detections": experimental_detections
173-
174200
}
175201
return result_dict

contentctl/actions/detection_testing/views/DetectionTestingViewCLI.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def setup(self):
4545

4646
self.showStatus()
4747

48+
# TODO (#267): Align test reporting more closely w/ status enums (as it relates to "untested")
4849
def showStatus(self, interval: int = 1):
4950

5051
while True:

contentctl/actions/detection_testing/views/DetectionTestingViewFile.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ class DetectionTestingViewFile(DetectionTestingView):
1313
output_filename: str = OUTPUT_FILENAME
1414

1515
def getOutputFilePath(self) -> pathlib.Path:
16-
1716
folder_path = pathlib.Path('.') / self.output_folder
1817
output_file = folder_path / self.output_filename
1918

@@ -27,13 +26,12 @@ def stop(self):
2726
output_file = self.getOutputFilePath()
2827

2928
folder_path.mkdir(parents=True, exist_ok=True)
30-
31-
29+
3230
result_dict = self.getSummaryObject()
33-
31+
3432
# use the yaml writer class
3533
with open(output_file, "w") as res:
36-
res.write(yaml.safe_dump(result_dict,sort_keys=False))
34+
res.write(yaml.safe_dump(result_dict, sort_keys=False))
3735

3836
def showStatus(self, interval: int = 60):
3937
pass

0 commit comments

Comments
 (0)