Skip to content

Commit 72e3354

Browse files
committed
Merge branch 'main' into add_drilldown_support
2 parents b3e7330 + bf6fe08 commit 72e3354

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

46 files changed

+1601
-838
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ This section is under active development. It will allow you to a [MITRE Map](ht
134134
Choose TYPE {detection, story} to create new content for the Content Pack. The tool will interactively ask a series of questions required for generating a basic piece of content and automatically add it to the Content Pack.
135135

136136
### contentctl inspect
137-
This section is under development. It will enable the user to perform an appinspect of the content pack in preparation for deployment onto a Splunk Instance or via Splunk Cloud.
137+
This section is under development. The inspect action performs a number of post-build validations. Primarily, it will enable the user to perform an appinspect of the content pack in preparation for deployment onto a Splunk Instance or via Splunk Cloud. It also compares detections in the new build against a prior build, confirming that any changed detections have had their versions incremented (this comparison happens at the savedsearch.conf level, which is why it must happen after the build). Please also note that new versions of contentctl may result in the generation of different savedsearches.conf files without any content changes in YML (new keys at the .conf level which will necessitate bumping of the version in the YML file).
138138

139139
### contentctl deploy
140140
The reason to build content is so that it can be deployed to your environment. However, deploying content to multiple servers and different types of infrastructure can be tricky and time-consuming. contentctl makes this easy by supporting a number of different deployment mechanisms. Deployment targets can be defined in [contentctl.yml](/contentctl/templates/contentctl_default.yml).

contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py

Lines changed: 35 additions & 28 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"
@@ -369,12 +374,6 @@ def execute(self):
369374
return
370375

371376
try:
372-
# NOTE: (THIS CODE HAS MOVED) we handle skipping entire detections differently than
373-
# we do skipping individual test cases; we skip entire detections by excluding
374-
# them to an entirely separate queue, while we skip individual test cases via the
375-
# BaseTest.skip() method, such as when we are skipping all integration tests (see
376-
# DetectionBuilder.skipIntegrationTests)
377-
# TODO: are we skipping by production status elsewhere?
378377
detection = self.sync_obj.inputQueue.pop()
379378
self.sync_obj.currentTestingQueue[self.get_name()] = detection
380379
except IndexError:
@@ -647,11 +646,7 @@ def execute_unit_test(
647646
# Set the mode and timeframe, if required
648647
kwargs = {"exec_mode": "blocking"}
649648

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)
649+
655650

656651
# Set earliest_time and latest_time appropriately if FORCE_ALL_TIME is False
657652
if not FORCE_ALL_TIME:
@@ -662,7 +657,23 @@ def execute_unit_test(
662657

663658
# Run the detection's search query
664659
try:
660+
# Iterate over baselines (if any)
661+
for baseline in detection.baselines:
662+
raise CannotRunBaselineException("Detection requires Execution of a Baseline, "
663+
"however Baseline execution is not "
664+
"currently supported in contentctl. Mark "
665+
"this as manual_test.")
665666
self.retry_search_until_timeout(detection, test, kwargs, test_start_time)
667+
except CannotRunBaselineException as e:
668+
# Init the test result and record a failure if there was an issue during the search
669+
test.result = UnitTestResult()
670+
test.result.set_job_content(
671+
None,
672+
self.infrastructure,
673+
TestResultStatus.ERROR,
674+
exception=e,
675+
duration=time.time() - test_start_time
676+
)
666677
except ContainerStoppedException as e:
667678
raise e
668679
except Exception as e:
@@ -1015,18 +1026,15 @@ def retry_search_until_timeout(
10151026
"""
10161027
# Get the start time and compute the timeout
10171028
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}"
1029+
search_stop_time = time.time() + self.sync_obj.timeout_seconds
1030+
1031+
# Make a copy of the search string since we may
1032+
# need to make some small changes to it below
1033+
search = detection.search
10261034

10271035
# 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
1036+
if not search.strip().startswith("|"):
1037+
if not search.strip().startswith("search "):
10301038
search = f"search {search}"
10311039

10321040
# exponential backoff for wait time
@@ -1179,8 +1187,7 @@ def retry_search_until_timeout(
11791187

11801188
return
11811189

1182-
def delete_attack_data(self, attack_data_files: list[UnitTestAttackData]):
1183-
return
1190+
def delete_attack_data(self, attack_data_files: list[TestAttackData]):
11841191
for attack_data_file in attack_data_files:
11851192
index = attack_data_file.custom_index or self.sync_obj.replay_index
11861193
host = attack_data_file.host or self.sync_obj.replay_host
@@ -1213,7 +1220,7 @@ def replay_attack_data_files(
12131220

12141221
def replay_attack_data_file(
12151222
self,
1216-
attack_data_file: UnitTestAttackData,
1223+
attack_data_file: TestAttackData,
12171224
tmp_dir: str,
12181225
test_group: TestGroup,
12191226
test_group_start_time: float,
@@ -1281,7 +1288,7 @@ def replay_attack_data_file(
12811288
def hec_raw_replay(
12821289
self,
12831290
tempfile: str,
1284-
attack_data_file: UnitTestAttackData,
1291+
attack_data_file: TestAttackData,
12851292
verify_ssl: bool = False,
12861293
):
12871294
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)