contentctl 4.3.2__py3-none-any.whl → 4.3.4__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 -27
- 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/enrichments/attack_enrichment.py +2 -1
- contentctl/enrichments/cve_enrichment.py +2 -2
- contentctl/objects/abstract_security_content_objects/detection_abstract.py +183 -90
- contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +1 -0
- contentctl/objects/annotated_types.py +6 -0
- contentctl/objects/base_test.py +1 -0
- contentctl/objects/base_test_result.py +1 -0
- contentctl/objects/config.py +27 -12
- contentctl/objects/correlation_search.py +35 -28
- contentctl/objects/detection_tags.py +8 -3
- 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 +3 -1
- contentctl/objects/risk_event.py +94 -76
- contentctl/objects/ssa_detection.py +1 -0
- contentctl/objects/story_tags.py +5 -3
- 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.4.dist-info}/METADATA +8 -8
- {contentctl-4.3.2.dist-info → contentctl-4.3.4.dist-info}/RECORD +34 -31
- {contentctl-4.3.2.dist-info → contentctl-4.3.4.dist-info}/LICENSE.md +0 -0
- {contentctl-4.3.2.dist-info → contentctl-4.3.4.dist-info}/WHEEL +0 -0
- {contentctl-4.3.2.dist-info → contentctl-4.3.4.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
|
|
@@ -244,12 +322,13 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
244
322
|
@property
|
|
245
323
|
def providing_technologies(self) -> List[ProvidingTechnology]:
|
|
246
324
|
return ProvidingTechnology.getProvidingTechFromSearch(self.search)
|
|
247
|
-
|
|
248
|
-
|
|
325
|
+
|
|
326
|
+
# TODO (#247): Refactor the risk property of detection_abstract
|
|
249
327
|
@computed_field
|
|
250
328
|
@property
|
|
251
329
|
def risk(self) -> list[dict[str, Any]]:
|
|
252
330
|
risk_objects: list[dict[str, str | int]] = []
|
|
331
|
+
# TODO (#246): "User Name" type should map to a "user" risk object and not "other"
|
|
253
332
|
risk_object_user_types = {'user', 'username', 'email address'}
|
|
254
333
|
risk_object_system_types = {'device', 'endpoint', 'hostname', 'ip address'}
|
|
255
334
|
process_threat_object_types = {'process name', 'process'}
|
|
@@ -307,14 +386,16 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
307
386
|
|
|
308
387
|
@computed_field
|
|
309
388
|
@property
|
|
310
|
-
def metadata(self) -> dict[str, str]:
|
|
389
|
+
def metadata(self) -> dict[str, str|float]:
|
|
311
390
|
# NOTE: we ignore the type error around self.status because we are using Pydantic's
|
|
312
391
|
# use_enum_values configuration
|
|
313
392
|
# https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.populate_by_name
|
|
393
|
+
|
|
314
394
|
return {
|
|
315
395
|
'detection_id': str(self.id),
|
|
316
396
|
'deprecated': '1' if self.status == DetectionStatus.deprecated.value else '0', # type: ignore
|
|
317
|
-
'detection_version': str(self.version)
|
|
397
|
+
'detection_version': str(self.version),
|
|
398
|
+
'publish_time': datetime.datetime(self.date.year,self.date.month,self.date.day,0,0,0,0,tzinfo=datetime.timezone.utc).timestamp()
|
|
318
399
|
}
|
|
319
400
|
|
|
320
401
|
@model_serializer
|
|
@@ -439,6 +520,9 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
439
520
|
|
|
440
521
|
self.cve_enrichment_func(__context)
|
|
441
522
|
|
|
523
|
+
# Derive TestGroups and IntegrationTests, adjust for ManualTests, skip as needed
|
|
524
|
+
self.adjust_tests_and_groups()
|
|
525
|
+
|
|
442
526
|
@field_validator('lookups', mode="before")
|
|
443
527
|
@classmethod
|
|
444
528
|
def getDetectionLookups(cls, v:list[str], info:ValidationInfo) -> list[Lookup]:
|
|
@@ -644,68 +728,65 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
644
728
|
# Found everything
|
|
645
729
|
return self
|
|
646
730
|
|
|
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
731
|
@field_validator("tests")
|
|
674
732
|
def tests_validate(
|
|
675
733
|
cls,
|
|
676
|
-
v: list[UnitTest | IntegrationTest],
|
|
734
|
+
v: list[UnitTest | IntegrationTest | ManualTest],
|
|
677
735
|
info: ValidationInfo
|
|
678
|
-
) -> list[UnitTest | IntegrationTest]:
|
|
736
|
+
) -> list[UnitTest | IntegrationTest | ManualTest]:
|
|
679
737
|
# Only production analytics require tests
|
|
680
738
|
if info.data.get("status", "") != DetectionStatus.production.value:
|
|
681
739
|
return v
|
|
682
740
|
|
|
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
|
-
|
|
741
|
+
# All types EXCEPT Correlation MUST have test(s). Any other type, including newly defined
|
|
742
|
+
# types, requires them. Accordingly, we do not need to do additional checks if the type is
|
|
743
|
+
# Correlation
|
|
744
|
+
if info.data.get("type", "") in SKIPPED_ANALYTICS_TYPES:
|
|
745
|
+
return v
|
|
746
|
+
|
|
747
|
+
# Manually tested detections are not required to have tests defined
|
|
748
|
+
tags: DetectionTags | None = info.data.get("tags", None)
|
|
749
|
+
if tags is not None and tags.manual_test is not None:
|
|
686
750
|
return v
|
|
687
751
|
|
|
688
752
|
# Ensure that there is at least 1 test
|
|
689
753
|
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
|
-
)
|
|
754
|
+
raise ValueError(
|
|
755
|
+
"At least one test is REQUIRED for production detection: " + info.data.get("name", "NO NAME FOUND")
|
|
756
|
+
)
|
|
705
757
|
|
|
706
758
|
# No issues - at least one test provided for production type requiring testing
|
|
707
759
|
return v
|
|
708
760
|
|
|
761
|
+
def skip_all_tests(self, message: str = "TEST SKIPPED") -> None:
|
|
762
|
+
"""
|
|
763
|
+
Given a message, skip all tests for this detection.
|
|
764
|
+
:param message: the message to set in the test result
|
|
765
|
+
"""
|
|
766
|
+
for test in self.tests:
|
|
767
|
+
test.skip(message=message)
|
|
768
|
+
|
|
769
|
+
def skip_manual_tests(self) -> None:
|
|
770
|
+
"""
|
|
771
|
+
Skips all ManualTests, if the manual_test flag is set; also raises an error if any other
|
|
772
|
+
test types are found for a manual_test detection
|
|
773
|
+
"""
|
|
774
|
+
# Skip all ManualTest
|
|
775
|
+
if self.tags.manual_test is not None:
|
|
776
|
+
for test in self.tests:
|
|
777
|
+
if isinstance(test, ManualTest):
|
|
778
|
+
test.skip(
|
|
779
|
+
message=(
|
|
780
|
+
"TEST SKIPPED (MANUAL): Detection marked as 'manual_test' with "
|
|
781
|
+
f"explanation: {self.tags.manual_test}"
|
|
782
|
+
)
|
|
783
|
+
)
|
|
784
|
+
else:
|
|
785
|
+
raise ValueError(
|
|
786
|
+
"A detection with the manual_test flag should only have tests of type "
|
|
787
|
+
"ManualTest"
|
|
788
|
+
)
|
|
789
|
+
|
|
709
790
|
def all_tests_successful(self) -> bool:
|
|
710
791
|
"""
|
|
711
792
|
Checks that all tests in the detection succeeded. If no tests are defined, consider that a
|
|
@@ -715,9 +796,11 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
715
796
|
:returns: bool where True indicates all tests succeeded (they existed, complete and were
|
|
716
797
|
PASS/SKIP)
|
|
717
798
|
"""
|
|
718
|
-
# If no tests are defined, we consider it a
|
|
799
|
+
# If no tests are defined, we consider it a success for the detection (this detection was
|
|
800
|
+
# skipped for testing). Note that the existence of at least one test is enforced by Pydantic
|
|
801
|
+
# validation already, with a few specific exceptions
|
|
719
802
|
if len(self.tests) == 0:
|
|
720
|
-
return
|
|
803
|
+
return True
|
|
721
804
|
|
|
722
805
|
# Iterate over tests
|
|
723
806
|
for test in self.tests:
|
|
@@ -740,7 +823,13 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
740
823
|
|
|
741
824
|
def get_summary(
|
|
742
825
|
self,
|
|
743
|
-
detection_fields: list[str] = [
|
|
826
|
+
detection_fields: list[str] = [
|
|
827
|
+
"name", "type", "status", "test_status", "source", "data_source", "search", "file_path"
|
|
828
|
+
],
|
|
829
|
+
detection_field_aliases: dict[str, str] = {
|
|
830
|
+
"status": "production_status", "test_status": "status", "source": "source_category"
|
|
831
|
+
},
|
|
832
|
+
tags_fields: list[str] = ["manual_test"],
|
|
744
833
|
test_result_fields: list[str] = ["success", "message", "exception", "status", "duration", "wait_duration"],
|
|
745
834
|
test_job_fields: list[str] = ["resultCount", "runDuration"],
|
|
746
835
|
) -> dict[str, Any]:
|
|
@@ -756,7 +845,21 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
756
845
|
|
|
757
846
|
# Grab the top level detection fields
|
|
758
847
|
for field in detection_fields:
|
|
759
|
-
|
|
848
|
+
value = getattr(self, field)
|
|
849
|
+
|
|
850
|
+
# Enums and Path objects cannot be serialized directly, so we convert it to a string
|
|
851
|
+
if isinstance(value, Enum) or isinstance(value, pathlib.Path):
|
|
852
|
+
value = str(value)
|
|
853
|
+
|
|
854
|
+
# Alias any fields as needed
|
|
855
|
+
if field in detection_field_aliases:
|
|
856
|
+
summary_dict[detection_field_aliases[field]] = value
|
|
857
|
+
else:
|
|
858
|
+
summary_dict[field] = value
|
|
859
|
+
|
|
860
|
+
# Grab fields from the tags
|
|
861
|
+
for field in tags_fields:
|
|
862
|
+
summary_dict[field] = getattr(self.tags, field)
|
|
760
863
|
|
|
761
864
|
# Set success based on whether all tests passed
|
|
762
865
|
summary_dict["success"] = self.all_tests_successful()
|
|
@@ -789,13 +892,3 @@ class Detection_Abstract(SecurityContentObject):
|
|
|
789
892
|
# Return the summary
|
|
790
893
|
|
|
791
894
|
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,9 +16,9 @@ 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
|
+
from contentctl.objects.annotated_types import APPID_TYPE
|
|
22
22
|
import tqdm
|
|
23
23
|
from functools import partialmethod
|
|
24
24
|
|
|
@@ -27,11 +27,13 @@ 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)
|
|
33
35
|
title: str = Field(description="Human-readable name used by the app. This can have special characters.")
|
|
34
|
-
appid: Optional[
|
|
36
|
+
appid: Optional[APPID_TYPE]= Field(default=None,description="Internal name used by your app. "
|
|
35
37
|
"It may ONLY have characters, numbers, and underscores. No other characters are allowed.")
|
|
36
38
|
version: str = Field(description="The version of your Content Pack. This must follow semantic versioning guidelines.")
|
|
37
39
|
description: Optional[str] = Field(default="description of app",description="Free text description of the Content Pack.")
|
|
@@ -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,13 +93,15 @@ 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
|
|
95
101
|
# https://docs.splunk.com/Documentation/Splunk/9.0.4/Admin/Appconf
|
|
96
102
|
uid: int = Field(ge=2, lt=100000, default_factory=lambda:random.randint(20000,100000))
|
|
97
103
|
title: str = Field(default="Content Pack",description="Human-readable name used by the app. This can have special characters.")
|
|
98
|
-
appid:
|
|
104
|
+
appid: APPID_TYPE = Field(default="ContentPack",description="Internal name used by your app. "
|
|
99
105
|
"It may ONLY have characters, numbers, and underscores. No other characters are allowed.")
|
|
100
106
|
version: str = Field(default="0.0.1",description="The version of your Content Pack. This must follow semantic versioning guidelines.", validate_default=True)
|
|
101
107
|
|
|
@@ -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)
|