contentctl 4.3.2__py3-none-any.whl → 4.3.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +35 -21
- contentctl/actions/detection_testing/views/DetectionTestingView.py +64 -38
- contentctl/actions/detection_testing/views/DetectionTestingViewCLI.py +1 -0
- contentctl/actions/detection_testing/views/DetectionTestingViewFile.py +3 -5
- contentctl/actions/test.py +55 -32
- contentctl/contentctl.py +3 -6
- contentctl/objects/abstract_security_content_objects/detection_abstract.py +180 -88
- contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +1 -0
- contentctl/objects/base_test.py +1 -0
- contentctl/objects/base_test_result.py +1 -0
- contentctl/objects/config.py +24 -9
- contentctl/objects/detection_tags.py +3 -0
- contentctl/objects/integration_test.py +3 -5
- contentctl/objects/integration_test_result.py +1 -5
- contentctl/objects/investigation.py +1 -0
- contentctl/objects/manual_test.py +32 -0
- contentctl/objects/manual_test_result.py +8 -0
- contentctl/objects/mitre_attack_enrichment.py +1 -0
- contentctl/objects/ssa_detection.py +1 -0
- contentctl/objects/story_tags.py +2 -0
- contentctl/objects/{unit_test_attack_data.py → test_attack_data.py} +4 -5
- contentctl/objects/test_group.py +3 -3
- contentctl/objects/unit_test.py +4 -11
- contentctl/output/templates/savedsearches_detections.j2 +1 -1
- {contentctl-4.3.2.dist-info → contentctl-4.3.3.dist-info}/METADATA +7 -7
- {contentctl-4.3.2.dist-info → contentctl-4.3.3.dist-info}/RECORD +29 -27
- {contentctl-4.3.2.dist-info → contentctl-4.3.3.dist-info}/LICENSE.md +0 -0
- {contentctl-4.3.2.dist-info → contentctl-4.3.3.dist-info}/WHEEL +0 -0
- {contentctl-4.3.2.dist-info → contentctl-4.3.3.dist-info}/entry_points.txt +0 -0
|
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|
|
2
2
|
from typing import TYPE_CHECKING, Union, Optional, List, Any, Annotated
|
|
3
3
|
import re
|
|
4
4
|
import pathlib
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
5
7
|
from pydantic import (
|
|
6
8
|
field_validator,
|
|
7
9
|
model_validator,
|
|
@@ -12,6 +14,7 @@ from pydantic import (
|
|
|
12
14
|
ConfigDict,
|
|
13
15
|
FilePath
|
|
14
16
|
)
|
|
17
|
+
|
|
15
18
|
from contentctl.objects.macro import Macro
|
|
16
19
|
from contentctl.objects.lookup import Lookup
|
|
17
20
|
if TYPE_CHECKING:
|
|
@@ -27,17 +30,25 @@ from contentctl.objects.enums import NistCategory
|
|
|
27
30
|
from contentctl.objects.detection_tags import DetectionTags
|
|
28
31
|
from contentctl.objects.deployment import Deployment
|
|
29
32
|
from contentctl.objects.unit_test import UnitTest
|
|
33
|
+
from contentctl.objects.manual_test import ManualTest
|
|
30
34
|
from contentctl.objects.test_group import TestGroup
|
|
31
35
|
from contentctl.objects.integration_test import IntegrationTest
|
|
32
36
|
from contentctl.objects.data_source import DataSource
|
|
37
|
+
from contentctl.objects.base_test_result import TestResultStatus
|
|
33
38
|
|
|
34
39
|
# from contentctl.objects.playbook import Playbook
|
|
35
40
|
from contentctl.objects.enums import ProvidingTechnology
|
|
36
41
|
from contentctl.enrichments.cve_enrichment import CveEnrichmentObj
|
|
37
|
-
|
|
42
|
+
import datetime
|
|
38
43
|
MISSING_SOURCES: set[str] = set()
|
|
39
44
|
|
|
45
|
+
# Those AnalyticsTypes that we do not test via contentctl
|
|
46
|
+
SKIPPED_ANALYTICS_TYPES: set[str] = {
|
|
47
|
+
AnalyticsType.Correlation.value
|
|
48
|
+
}
|
|
40
49
|
|
|
50
|
+
|
|
51
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
41
52
|
class Detection_Abstract(SecurityContentObject):
|
|
42
53
|
model_config = ConfigDict(use_enum_values=True)
|
|
43
54
|
|
|
@@ -57,7 +68,7 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
57
68
|
# default mode, 'smart'
|
|
58
69
|
# https://docs.pydantic.dev/latest/concepts/unions/#left-to-right-mode
|
|
59
70
|
# https://github.com/pydantic/pydantic/issues/9101#issuecomment-2019032541
|
|
60
|
-
tests: List[Annotated[Union[UnitTest, IntegrationTest], Field(union_mode='left_to_right')]] = []
|
|
71
|
+
tests: List[Annotated[Union[UnitTest, IntegrationTest, ManualTest], Field(union_mode='left_to_right')]] = []
|
|
61
72
|
# A list of groups of tests, relying on the same data
|
|
62
73
|
test_groups: Union[list[TestGroup], None] = Field(None, validate_default=True)
|
|
63
74
|
|
|
@@ -115,35 +126,102 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
115
126
|
|
|
116
127
|
return value
|
|
117
128
|
|
|
118
|
-
|
|
119
|
-
@classmethod
|
|
120
|
-
def validate_test_groups(
|
|
121
|
-
cls,
|
|
122
|
-
value: Union[None, List[TestGroup]],
|
|
123
|
-
info: ValidationInfo
|
|
124
|
-
) -> Union[List[TestGroup], None]:
|
|
129
|
+
def adjust_tests_and_groups(self) -> None:
|
|
125
130
|
"""
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
:param values: a dict of the other fields in the Detection model
|
|
131
|
+
Converts UnitTest to ManualTest as needed, B=builds the `test_groups` field, constructing
|
|
132
|
+
the model from the list of unit tests. Also, preemptively skips all manual tests, as well as
|
|
133
|
+
tests for experimental/deprecated detections and Correlation type detections.
|
|
130
134
|
"""
|
|
131
|
-
#
|
|
132
|
-
|
|
133
|
-
|
|
135
|
+
# Since ManualTest and UnitTest are not differentiable without looking at the manual_test
|
|
136
|
+
# tag, Pydantic builds all tests as UnitTest objects. If we see the manual_test flag, we
|
|
137
|
+
# convert these to ManualTest
|
|
138
|
+
tmp: list[UnitTest | IntegrationTest | ManualTest] = []
|
|
139
|
+
if self.tags.manual_test is not None:
|
|
140
|
+
for test in self.tests:
|
|
141
|
+
if not isinstance(test, UnitTest):
|
|
142
|
+
raise ValueError(
|
|
143
|
+
"At this point of intialization, tests should only be UnitTest objects, "
|
|
144
|
+
f"but encountered a {type(test)}."
|
|
145
|
+
)
|
|
146
|
+
# Create the manual test and skip it upon creation (cannot test via contentctl)
|
|
147
|
+
manual_test = ManualTest(
|
|
148
|
+
name=test.name,
|
|
149
|
+
attack_data=test.attack_data
|
|
150
|
+
)
|
|
151
|
+
tmp.append(manual_test)
|
|
152
|
+
self.tests = tmp
|
|
134
153
|
|
|
135
|
-
# iterate over the
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
154
|
+
# iterate over the tests and create a TestGroup (and as a result, an IntegrationTest) for
|
|
155
|
+
# each unit test
|
|
156
|
+
self.test_groups = []
|
|
157
|
+
for test in self.tests:
|
|
158
|
+
# We only derive TestGroups from UnitTests (ManualTest is ignored and IntegrationTests
|
|
159
|
+
# have not been created yet)
|
|
160
|
+
if isinstance(test, UnitTest):
|
|
161
|
+
test_group = TestGroup.derive_from_unit_test(test, self.name)
|
|
162
|
+
self.test_groups.append(test_group)
|
|
142
163
|
|
|
143
164
|
# now add each integration test to the list of tests
|
|
144
|
-
for test_group in test_groups:
|
|
145
|
-
tests.append(test_group.integration_test)
|
|
146
|
-
|
|
165
|
+
for test_group in self.test_groups:
|
|
166
|
+
self.tests.append(test_group.integration_test)
|
|
167
|
+
|
|
168
|
+
# Skip all manual tests
|
|
169
|
+
self.skip_manual_tests()
|
|
170
|
+
|
|
171
|
+
# NOTE: we ignore the type error around self.status because we are using Pydantic's
|
|
172
|
+
# use_enum_values configuration
|
|
173
|
+
# https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
|
|
174
|
+
|
|
175
|
+
# Skip tests for non-production detections
|
|
176
|
+
if self.status != DetectionStatus.production.value: # type: ignore
|
|
177
|
+
self.skip_all_tests(f"TEST SKIPPED: Detection is non-production ({self.status})")
|
|
178
|
+
|
|
179
|
+
# Skip tests for detecton types like Correlation which are not supported via contentctl
|
|
180
|
+
if self.type in SKIPPED_ANALYTICS_TYPES:
|
|
181
|
+
self.skip_all_tests(
|
|
182
|
+
f"TEST SKIPPED: Detection type {self.type} cannot be tested by contentctl"
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def test_status(self) -> TestResultStatus | None:
|
|
187
|
+
"""
|
|
188
|
+
Returns the collective status of the detections tests. If any test status has yet to be set,
|
|
189
|
+
None is returned.If any test failed or errored, FAIL is returned. If all tests were skipped,
|
|
190
|
+
SKIP is returned. If at least one test passed and the rest passed or skipped, PASS is
|
|
191
|
+
returned.
|
|
192
|
+
"""
|
|
193
|
+
# If the detection has no tests, we consider it to have been skipped (only non-production,
|
|
194
|
+
# non-manual, non-correlation detections are allowed to have no tests defined)
|
|
195
|
+
if len(self.tests) == 0:
|
|
196
|
+
return TestResultStatus.SKIP
|
|
197
|
+
|
|
198
|
+
passed = 0
|
|
199
|
+
skipped = 0
|
|
200
|
+
for test in self.tests:
|
|
201
|
+
# If the result/status of any test has not yet been set, return None
|
|
202
|
+
if test.result is None or test.result.status is None:
|
|
203
|
+
return None
|
|
204
|
+
elif test.result.status == TestResultStatus.ERROR or test.result.status == TestResultStatus.FAIL:
|
|
205
|
+
# If any test failed or errored, return fail (we don't return the error state at
|
|
206
|
+
# the aggregate detection level)
|
|
207
|
+
return TestResultStatus.FAIL
|
|
208
|
+
elif test.result.status == TestResultStatus.SKIP:
|
|
209
|
+
skipped += 1
|
|
210
|
+
elif test.result.status == TestResultStatus.PASS:
|
|
211
|
+
passed += 1
|
|
212
|
+
else:
|
|
213
|
+
raise ValueError(
|
|
214
|
+
f"Undefined test status for test ({test.name}) in detection ({self.name})"
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
# If at least one of the tests passed and the rest passed or skipped, report pass
|
|
218
|
+
if passed > 0 and (passed + skipped) == len(self.tests):
|
|
219
|
+
return TestResultStatus.PASS
|
|
220
|
+
elif skipped == len(self.tests):
|
|
221
|
+
# If all tests skipped, return skip
|
|
222
|
+
return TestResultStatus.SKIP
|
|
223
|
+
|
|
224
|
+
raise ValueError(f"Undefined overall test status for detection: {self.name}")
|
|
147
225
|
|
|
148
226
|
@computed_field
|
|
149
227
|
@property
|
|
@@ -307,14 +385,16 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
307
385
|
|
|
308
386
|
@computed_field
|
|
309
387
|
@property
|
|
310
|
-
def metadata(self) -> dict[str, str]:
|
|
388
|
+
def metadata(self) -> dict[str, str|float]:
|
|
311
389
|
# NOTE: we ignore the type error around self.status because we are using Pydantic's
|
|
312
390
|
# use_enum_values configuration
|
|
313
391
|
# https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
|
|
392
|
+
|
|
314
393
|
return {
|
|
315
394
|
'detection_id': str(self.id),
|
|
316
395
|
'deprecated': '1' if self.status == DetectionStatus.deprecated.value else '0', # type: ignore
|
|
317
|
-
'detection_version': str(self.version)
|
|
396
|
+
'detection_version': str(self.version),
|
|
397
|
+
'publish_time': datetime.datetime(self.date.year,self.date.month,self.date.day,0,0,0,0,tzinfo=datetime.timezone.utc).timestamp()
|
|
318
398
|
}
|
|
319
399
|
|
|
320
400
|
@model_serializer
|
|
@@ -439,6 +519,9 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
439
519
|
|
|
440
520
|
self.cve_enrichment_func(__context)
|
|
441
521
|
|
|
522
|
+
# Derive TestGroups and IntegrationTests, adjust for ManualTests, skip as needed
|
|
523
|
+
self.adjust_tests_and_groups()
|
|
524
|
+
|
|
442
525
|
@field_validator('lookups', mode="before")
|
|
443
526
|
@classmethod
|
|
444
527
|
def getDetectionLookups(cls, v:list[str], info:ValidationInfo) -> list[Lookup]:
|
|
@@ -644,68 +727,65 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
644
727
|
# Found everything
|
|
645
728
|
return self
|
|
646
729
|
|
|
647
|
-
@model_validator(mode='after')
|
|
648
|
-
def ensurePresenceOfRequiredTests(self):
|
|
649
|
-
# NOTE: we ignore the type error around self.status because we are using Pydantic's
|
|
650
|
-
# use_enum_values configuration
|
|
651
|
-
# https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
|
|
652
|
-
|
|
653
|
-
# Only production analytics require tests
|
|
654
|
-
if self.status != DetectionStatus.production.value: # type: ignore
|
|
655
|
-
return self
|
|
656
|
-
|
|
657
|
-
# All types EXCEPT Correlation MUST have test(s). Any other type, including newly defined types, requires them.
|
|
658
|
-
# Accordingly, we do not need to do additional checks if the type is Correlation
|
|
659
|
-
if self.type in set([AnalyticsType.Correlation.value]):
|
|
660
|
-
return self
|
|
661
|
-
|
|
662
|
-
if self.tags.manual_test is not None:
|
|
663
|
-
for test in self.tests:
|
|
664
|
-
test.skip(
|
|
665
|
-
f"TEST SKIPPED: Detection marked as 'manual_test' with explanation: '{self.tags.manual_test}'"
|
|
666
|
-
)
|
|
667
|
-
|
|
668
|
-
if len(self.tests) == 0:
|
|
669
|
-
raise ValueError(f"At least one test is REQUIRED for production detection: {self.name}")
|
|
670
|
-
|
|
671
|
-
return self
|
|
672
|
-
|
|
673
730
|
@field_validator("tests")
|
|
674
731
|
def tests_validate(
|
|
675
732
|
cls,
|
|
676
|
-
v: list[UnitTest | IntegrationTest],
|
|
733
|
+
v: list[UnitTest | IntegrationTest | ManualTest],
|
|
677
734
|
info: ValidationInfo
|
|
678
|
-
) -> list[UnitTest | IntegrationTest]:
|
|
735
|
+
) -> list[UnitTest | IntegrationTest | ManualTest]:
|
|
679
736
|
# Only production analytics require tests
|
|
680
737
|
if info.data.get("status", "") != DetectionStatus.production.value:
|
|
681
738
|
return v
|
|
682
739
|
|
|
683
|
-
# All types EXCEPT Correlation MUST have test(s). Any other type, including newly defined
|
|
684
|
-
# Accordingly, we do not need to do additional checks if the type is
|
|
685
|
-
|
|
740
|
+
# All types EXCEPT Correlation MUST have test(s). Any other type, including newly defined
|
|
741
|
+
# types, requires them. Accordingly, we do not need to do additional checks if the type is
|
|
742
|
+
# Correlation
|
|
743
|
+
if info.data.get("type", "") in SKIPPED_ANALYTICS_TYPES:
|
|
744
|
+
return v
|
|
745
|
+
|
|
746
|
+
# Manually tested detections are not required to have tests defined
|
|
747
|
+
tags: DetectionTags | None = info.data.get("tags", None)
|
|
748
|
+
if tags is not None and tags.manual_test is not None:
|
|
686
749
|
return v
|
|
687
750
|
|
|
688
751
|
# Ensure that there is at least 1 test
|
|
689
752
|
if len(v) == 0:
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
# Note that this fake UnitTest (and by extension, Integration Test) will NOT be generated
|
|
694
|
-
# if there ARE test(s) defined for a Detection.
|
|
695
|
-
placeholder_test = UnitTest( # type: ignore
|
|
696
|
-
name="PLACEHOLDER FOR DETECTION TAGGED MANUAL_TEST WITH NO TESTS SPECIFIED IN YML FILE",
|
|
697
|
-
attack_data=[]
|
|
698
|
-
)
|
|
699
|
-
return [placeholder_test]
|
|
700
|
-
|
|
701
|
-
else:
|
|
702
|
-
raise ValueError(
|
|
703
|
-
"At least one test is REQUIRED for production detection: " + info.data.get("name", "NO NAME FOUND")
|
|
704
|
-
)
|
|
753
|
+
raise ValueError(
|
|
754
|
+
"At least one test is REQUIRED for production detection: " + info.data.get("name", "NO NAME FOUND")
|
|
755
|
+
)
|
|
705
756
|
|
|
706
757
|
# No issues - at least one test provided for production type requiring testing
|
|
707
758
|
return v
|
|
708
759
|
|
|
760
|
+
def skip_all_tests(self, message: str = "TEST SKIPPED") -> None:
|
|
761
|
+
"""
|
|
762
|
+
Given a message, skip all tests for this detection.
|
|
763
|
+
:param message: the message to set in the test result
|
|
764
|
+
"""
|
|
765
|
+
for test in self.tests:
|
|
766
|
+
test.skip(message=message)
|
|
767
|
+
|
|
768
|
+
def skip_manual_tests(self) -> None:
|
|
769
|
+
"""
|
|
770
|
+
Skips all ManualTests, if the manual_test flag is set; also raises an error if any other
|
|
771
|
+
test types are found for a manual_test detection
|
|
772
|
+
"""
|
|
773
|
+
# Skip all ManualTest
|
|
774
|
+
if self.tags.manual_test is not None:
|
|
775
|
+
for test in self.tests:
|
|
776
|
+
if isinstance(test, ManualTest):
|
|
777
|
+
test.skip(
|
|
778
|
+
message=(
|
|
779
|
+
"TEST SKIPPED (MANUAL): Detection marked as 'manual_test' with "
|
|
780
|
+
f"explanation: {self.tags.manual_test}"
|
|
781
|
+
)
|
|
782
|
+
)
|
|
783
|
+
else:
|
|
784
|
+
raise ValueError(
|
|
785
|
+
"A detection with the manual_test flag should only have tests of type "
|
|
786
|
+
"ManualTest"
|
|
787
|
+
)
|
|
788
|
+
|
|
709
789
|
def all_tests_successful(self) -> bool:
|
|
710
790
|
"""
|
|
711
791
|
Checks that all tests in the detection succeeded. If no tests are defined, consider that a
|
|
@@ -715,9 +795,11 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
715
795
|
:returns: bool where True indicates all tests succeeded (they existed, complete and were
|
|
716
796
|
PASS/SKIP)
|
|
717
797
|
"""
|
|
718
|
-
# If no tests are defined, we consider it a
|
|
798
|
+
# If no tests are defined, we consider it a success for the detection (this detection was
|
|
799
|
+
# skipped for testing). Note that the existence of at least one test is enforced by Pydantic
|
|
800
|
+
# validation already, with a few specific exceptions
|
|
719
801
|
if len(self.tests) == 0:
|
|
720
|
-
return
|
|
802
|
+
return True
|
|
721
803
|
|
|
722
804
|
# Iterate over tests
|
|
723
805
|
for test in self.tests:
|
|
@@ -740,7 +822,13 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
740
822
|
|
|
741
823
|
def get_summary(
|
|
742
824
|
self,
|
|
743
|
-
detection_fields: list[str] = [
|
|
825
|
+
detection_fields: list[str] = [
|
|
826
|
+
"name", "type", "status", "test_status", "source", "data_source", "search", "file_path"
|
|
827
|
+
],
|
|
828
|
+
detection_field_aliases: dict[str, str] = {
|
|
829
|
+
"status": "production_status", "test_status": "status", "source": "source_category"
|
|
830
|
+
},
|
|
831
|
+
tags_fields: list[str] = ["manual_test"],
|
|
744
832
|
test_result_fields: list[str] = ["success", "message", "exception", "status", "duration", "wait_duration"],
|
|
745
833
|
test_job_fields: list[str] = ["resultCount", "runDuration"],
|
|
746
834
|
) -> dict[str, Any]:
|
|
@@ -756,7 +844,21 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
756
844
|
|
|
757
845
|
# Grab the top level detection fields
|
|
758
846
|
for field in detection_fields:
|
|
759
|
-
|
|
847
|
+
value = getattr(self, field)
|
|
848
|
+
|
|
849
|
+
# Enums and Path objects cannot be serialized directly, so we convert it to a string
|
|
850
|
+
if isinstance(value, Enum) or isinstance(value, pathlib.Path):
|
|
851
|
+
value = str(value)
|
|
852
|
+
|
|
853
|
+
# Alias any fields as needed
|
|
854
|
+
if field in detection_field_aliases:
|
|
855
|
+
summary_dict[detection_field_aliases[field]] = value
|
|
856
|
+
else:
|
|
857
|
+
summary_dict[field] = value
|
|
858
|
+
|
|
859
|
+
# Grab fields from the tags
|
|
860
|
+
for field in tags_fields:
|
|
861
|
+
summary_dict[field] = getattr(self.tags, field)
|
|
760
862
|
|
|
761
863
|
# Set success based on whether all tests passed
|
|
762
864
|
summary_dict["success"] = self.all_tests_successful()
|
|
@@ -789,13 +891,3 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
789
891
|
# Return the summary
|
|
790
892
|
|
|
791
893
|
return summary_dict
|
|
792
|
-
|
|
793
|
-
def getMetadata(self) -> dict[str, str]:
|
|
794
|
-
# NOTE: we ignore the type error around self.status because we are using Pydantic's
|
|
795
|
-
# use_enum_values configuration
|
|
796
|
-
# https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
|
|
797
|
-
return {
|
|
798
|
-
'detection_id': str(self.id),
|
|
799
|
-
'deprecated': '1' if self.status == DetectionStatus.deprecated.value else '0', # type: ignore
|
|
800
|
-
'detection_version': str(self.version)
|
|
801
|
-
}
|
contentctl/objects/base_test.py
CHANGED
|
@@ -7,6 +7,7 @@ from splunklib.data import Record
|
|
|
7
7
|
from contentctl.helper.utils import Utils
|
|
8
8
|
|
|
9
9
|
|
|
10
|
+
# TODO (#267): Align test reporting more closely w/ status enums (as it relates to "untested")
|
|
10
11
|
# TODO (PEX-432): add status "UNSET" so that we can make sure the result is always of this enum
|
|
11
12
|
# type; remove mypy ignores associated w/ these typing issues once we do
|
|
12
13
|
class TestResultStatus(str, Enum):
|
contentctl/objects/config.py
CHANGED
|
@@ -16,7 +16,7 @@ import pathlib
|
|
|
16
16
|
from contentctl.helper.utils import Utils
|
|
17
17
|
from urllib.parse import urlparse
|
|
18
18
|
from abc import ABC, abstractmethod
|
|
19
|
-
from contentctl.objects.enums import PostTestBehavior
|
|
19
|
+
from contentctl.objects.enums import PostTestBehavior, DetectionTestingMode
|
|
20
20
|
from contentctl.objects.detection import Detection
|
|
21
21
|
|
|
22
22
|
import tqdm
|
|
@@ -27,6 +27,8 @@ COMMON_INFORMATION_MODEL_UID = 1621
|
|
|
27
27
|
|
|
28
28
|
SPLUNKBASE_URL = "https://splunkbase.splunk.com/app/{uid}/release/{version}/download"
|
|
29
29
|
|
|
30
|
+
|
|
31
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
30
32
|
class App_Base(BaseModel,ABC):
|
|
31
33
|
model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
|
|
32
34
|
uid: Optional[int] = Field(default=None)
|
|
@@ -51,6 +53,8 @@ class App_Base(BaseModel,ABC):
|
|
|
51
53
|
if not config.getLocalAppDir().exists():
|
|
52
54
|
config.getLocalAppDir().mkdir(parents=True)
|
|
53
55
|
|
|
56
|
+
|
|
57
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
54
58
|
class TestApp(App_Base):
|
|
55
59
|
model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
|
|
56
60
|
hardcoded_path: Optional[Union[FilePath,HttpUrl]] = Field(default=None, description="This may be a relative or absolute link to a file OR an HTTP URL linking to your app.")
|
|
@@ -89,6 +93,8 @@ class TestApp(App_Base):
|
|
|
89
93
|
|
|
90
94
|
return str(destination)
|
|
91
95
|
|
|
96
|
+
|
|
97
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
92
98
|
class CustomApp(App_Base):
|
|
93
99
|
model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
|
|
94
100
|
# Fields required for app.conf based on
|
|
@@ -149,6 +155,7 @@ class CustomApp(App_Base):
|
|
|
149
155
|
return str(destination)
|
|
150
156
|
|
|
151
157
|
|
|
158
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
152
159
|
class Config_Base(BaseModel):
|
|
153
160
|
model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
|
|
154
161
|
|
|
@@ -167,6 +174,7 @@ class init(Config_Base):
|
|
|
167
174
|
pass
|
|
168
175
|
|
|
169
176
|
|
|
177
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
170
178
|
class validate(Config_Base):
|
|
171
179
|
model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
|
|
172
180
|
enrichments: bool = Field(default=False, description="Enable MITRE, APP, and CVE Enrichments. "\
|
|
@@ -186,7 +194,7 @@ class report(validate):
|
|
|
186
194
|
return self.path/"reporting/"
|
|
187
195
|
|
|
188
196
|
|
|
189
|
-
|
|
197
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
190
198
|
class build(validate):
|
|
191
199
|
model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
|
|
192
200
|
build_path: DirectoryPath = Field(default=DirectoryPath("dist/"), title="Target path for all build outputs")
|
|
@@ -255,6 +263,7 @@ class new(Config_Base):
|
|
|
255
263
|
type: NewContentType = Field(default=NewContentType.detection, description="Specify the type of content you would like to create.")
|
|
256
264
|
|
|
257
265
|
|
|
266
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
258
267
|
class deploy_acs(inspect):
|
|
259
268
|
model_config = ConfigDict(use_enum_values=True,validate_default=False, arbitrary_types_allowed=True)
|
|
260
269
|
#ignore linter error
|
|
@@ -262,6 +271,7 @@ class deploy_acs(inspect):
|
|
|
262
271
|
splunk_cloud_stack: str = Field(description="The name of your Splunk Cloud Stack")
|
|
263
272
|
|
|
264
273
|
|
|
274
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
265
275
|
class Infrastructure(BaseModel):
|
|
266
276
|
model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
|
|
267
277
|
splunk_app_username:str = Field(default="admin", description="Username for logging in to your Splunk Server")
|
|
@@ -273,11 +283,13 @@ class Infrastructure(BaseModel):
|
|
|
273
283
|
instance_name: str = Field(...)
|
|
274
284
|
|
|
275
285
|
|
|
286
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
276
287
|
class Container(Infrastructure):
|
|
277
288
|
model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
|
|
278
289
|
instance_address:str = Field(default="localhost", description="Address of your splunk server.")
|
|
279
290
|
|
|
280
291
|
|
|
292
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
281
293
|
class ContainerSettings(BaseModel):
|
|
282
294
|
model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
|
|
283
295
|
leave_running: bool = Field(default=True, description="Leave container running after it is first "
|
|
@@ -302,11 +314,14 @@ class All(BaseModel):
|
|
|
302
314
|
#Doesn't need any extra logic
|
|
303
315
|
pass
|
|
304
316
|
|
|
317
|
+
|
|
318
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
305
319
|
class Changes(BaseModel):
|
|
306
320
|
model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
|
|
307
321
|
target_branch:str = Field(...,description="The target branch to diff against. Note that this includes uncommitted changes in the working directory as well.")
|
|
308
322
|
|
|
309
323
|
|
|
324
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
310
325
|
class Selected(BaseModel):
|
|
311
326
|
model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
|
|
312
327
|
files:List[FilePath] = Field(...,description="List of detection files to test, separated by spaces.")
|
|
@@ -672,17 +687,14 @@ class test_common(build):
|
|
|
672
687
|
|
|
673
688
|
def getModeName(self)->str:
|
|
674
689
|
if isinstance(self.mode, All):
|
|
675
|
-
return
|
|
690
|
+
return DetectionTestingMode.all.value
|
|
676
691
|
elif isinstance(self.mode, Changes):
|
|
677
|
-
return
|
|
692
|
+
return DetectionTestingMode.changes.value
|
|
678
693
|
else:
|
|
679
|
-
return
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
694
|
+
return DetectionTestingMode.selected.value
|
|
684
695
|
|
|
685
696
|
|
|
697
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
686
698
|
class test(test_common):
|
|
687
699
|
model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
|
|
688
700
|
container_settings:ContainerSettings = ContainerSettings()
|
|
@@ -747,6 +759,9 @@ class test(test_common):
|
|
|
747
759
|
|
|
748
760
|
|
|
749
761
|
TEST_ARGS_ENV = "CONTENTCTL_TEST_INFRASTRUCTURES"
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
750
765
|
class test_servers(test_common):
|
|
751
766
|
model_config = ConfigDict(use_enum_values=True,validate_default=True, arbitrary_types_allowed=True)
|
|
752
767
|
test_instances:List[Infrastructure] = Field([],description="Test against one or more preconfigured servers.", validate_default=True)
|
|
@@ -35,6 +35,7 @@ from contentctl.objects.enums import (
|
|
|
35
35
|
from contentctl.objects.atomic import AtomicTest
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
38
39
|
class DetectionTags(BaseModel):
|
|
39
40
|
# detection spec
|
|
40
41
|
model_config = ConfigDict(use_enum_values=True, validate_default=False)
|
|
@@ -106,6 +107,8 @@ class DetectionTags(BaseModel):
|
|
|
106
107
|
# TODO (#221): mappings should be fleshed out into a proper class
|
|
107
108
|
mappings: Optional[List] = None
|
|
108
109
|
# annotations: Optional[dict] = None
|
|
110
|
+
|
|
111
|
+
# TODO (#268): Validate manual_test has length > 0 if not None
|
|
109
112
|
manual_test: Optional[str] = None
|
|
110
113
|
|
|
111
114
|
# The following validator is temporarily disabled pending further discussions
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
from typing import Union
|
|
2
|
-
|
|
3
1
|
from pydantic import Field
|
|
4
2
|
|
|
5
3
|
from contentctl.objects.base_test import BaseTest, TestType
|
|
@@ -13,10 +11,10 @@ class IntegrationTest(BaseTest):
|
|
|
13
11
|
An integration test for a detection against ES
|
|
14
12
|
"""
|
|
15
13
|
# The test type (integration)
|
|
16
|
-
test_type: TestType = Field(TestType.INTEGRATION)
|
|
14
|
+
test_type: TestType = Field(default=TestType.INTEGRATION)
|
|
17
15
|
|
|
18
16
|
# The test result
|
|
19
|
-
result:
|
|
17
|
+
result: IntegrationTestResult | None = None
|
|
20
18
|
|
|
21
19
|
@classmethod
|
|
22
20
|
def derive_from_unit_test(cls, unit_test: UnitTest) -> "IntegrationTest":
|
|
@@ -36,7 +34,7 @@ class IntegrationTest(BaseTest):
|
|
|
36
34
|
Skip the test by setting its result status
|
|
37
35
|
:param message: the reason for skipping
|
|
38
36
|
"""
|
|
39
|
-
self.result = IntegrationTestResult(
|
|
37
|
+
self.result = IntegrationTestResult( # type: ignore
|
|
40
38
|
message=message,
|
|
41
39
|
status=TestResultStatus.SKIP
|
|
42
40
|
)
|
|
@@ -1,13 +1,9 @@
|
|
|
1
|
-
from typing import Optional
|
|
2
1
|
from contentctl.objects.base_test_result import BaseTestResult
|
|
3
2
|
|
|
4
3
|
|
|
5
|
-
SAVED_SEARCH_TEMPLATE = "{server}:{web_port}/en-US/{path}"
|
|
6
|
-
|
|
7
|
-
|
|
8
4
|
class IntegrationTestResult(BaseTestResult):
|
|
9
5
|
"""
|
|
10
6
|
An integration test result
|
|
11
7
|
"""
|
|
12
8
|
# the total time we slept waiting for the detection to fire after activating it
|
|
13
|
-
wait_duration:
|
|
9
|
+
wait_duration: int | None = None
|
|
@@ -9,6 +9,7 @@ from contentctl.objects.enums import DataModel
|
|
|
9
9
|
from contentctl.objects.investigation_tags import InvestigationTags
|
|
10
10
|
|
|
11
11
|
|
|
12
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
12
13
|
class Investigation(SecurityContentObject):
|
|
13
14
|
model_config = ConfigDict(use_enum_values=True,validate_default=False)
|
|
14
15
|
type: str = Field(...,pattern="^Investigation$")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pydantic import Field
|
|
4
|
+
|
|
5
|
+
from contentctl.objects.test_attack_data import TestAttackData
|
|
6
|
+
from contentctl.objects.manual_test_result import ManualTestResult
|
|
7
|
+
from contentctl.objects.base_test import BaseTest, TestType
|
|
8
|
+
from contentctl.objects.base_test_result import TestResultStatus
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ManualTest(BaseTest):
|
|
12
|
+
"""
|
|
13
|
+
A manual test for a detection
|
|
14
|
+
"""
|
|
15
|
+
# The test type (manual)
|
|
16
|
+
test_type: TestType = Field(default=TestType.MANUAL)
|
|
17
|
+
|
|
18
|
+
# The attack data to be ingested for the manual test
|
|
19
|
+
attack_data: list[TestAttackData]
|
|
20
|
+
|
|
21
|
+
# The result of the manual test
|
|
22
|
+
result: ManualTestResult | None = None
|
|
23
|
+
|
|
24
|
+
def skip(self, message: str) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Skip the test by setting its result status
|
|
27
|
+
:param message: the reason for skipping
|
|
28
|
+
"""
|
|
29
|
+
self.result = ManualTestResult( # type: ignore
|
|
30
|
+
message=message,
|
|
31
|
+
status=TestResultStatus.SKIP
|
|
32
|
+
)
|
|
@@ -82,6 +82,7 @@ class MitreAttackGroup(BaseModel):
|
|
|
82
82
|
return []
|
|
83
83
|
return contributors
|
|
84
84
|
|
|
85
|
+
# TODO (#266): disable the use_enum_values configuration
|
|
85
86
|
class MitreAttackEnrichment(BaseModel):
|
|
86
87
|
ConfigDict(use_enum_values=True)
|
|
87
88
|
mitre_attack_id: Annotated[str, Field(pattern=r"^T\d{4}(.\d{3})?$")] = Field(...)
|