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.
Files changed (29) hide show
  1. contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +35 -21
  2. contentctl/actions/detection_testing/views/DetectionTestingView.py +64 -38
  3. contentctl/actions/detection_testing/views/DetectionTestingViewCLI.py +1 -0
  4. contentctl/actions/detection_testing/views/DetectionTestingViewFile.py +3 -5
  5. contentctl/actions/test.py +55 -32
  6. contentctl/contentctl.py +3 -6
  7. contentctl/objects/abstract_security_content_objects/detection_abstract.py +180 -88
  8. contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +1 -0
  9. contentctl/objects/base_test.py +1 -0
  10. contentctl/objects/base_test_result.py +1 -0
  11. contentctl/objects/config.py +24 -9
  12. contentctl/objects/detection_tags.py +3 -0
  13. contentctl/objects/integration_test.py +3 -5
  14. contentctl/objects/integration_test_result.py +1 -5
  15. contentctl/objects/investigation.py +1 -0
  16. contentctl/objects/manual_test.py +32 -0
  17. contentctl/objects/manual_test_result.py +8 -0
  18. contentctl/objects/mitre_attack_enrichment.py +1 -0
  19. contentctl/objects/ssa_detection.py +1 -0
  20. contentctl/objects/story_tags.py +2 -0
  21. contentctl/objects/{unit_test_attack_data.py → test_attack_data.py} +4 -5
  22. contentctl/objects/test_group.py +3 -3
  23. contentctl/objects/unit_test.py +4 -11
  24. contentctl/output/templates/savedsearches_detections.j2 +1 -1
  25. {contentctl-4.3.2.dist-info → contentctl-4.3.3.dist-info}/METADATA +7 -7
  26. {contentctl-4.3.2.dist-info → contentctl-4.3.3.dist-info}/RECORD +29 -27
  27. {contentctl-4.3.2.dist-info → contentctl-4.3.3.dist-info}/LICENSE.md +0 -0
  28. {contentctl-4.3.2.dist-info → contentctl-4.3.3.dist-info}/WHEEL +0 -0
  29. {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
- @field_validator("test_groups")
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
- Validates the `test_groups` field and constructs the model from the list of unit tests
127
- if no explicit construct was provided
128
- :param value: the value of the field `test_groups`
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
- # if the value was not the None default, do nothing
132
- if value is not None:
133
- return value
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 unit tests and create a TestGroup (and as a result, an IntegrationTest) for each
136
- test_groups: list[TestGroup] = []
137
- tests: list[UnitTest | IntegrationTest] = info.data.get("tests") # type: ignore
138
- unit_test: UnitTest
139
- for unit_test in tests: # type: ignore
140
- test_group = TestGroup.derive_from_unit_test(unit_test, info.data.get("name")) # type: ignore
141
- test_groups.append(test_group)
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
- return test_groups
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 types, requires them.
684
- # Accordingly, we do not need to do additional checks if the type is Correlation
685
- if info.data.get("type", "") in set([AnalyticsType.Correlation.value]):
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
- if info.data.get("tags", None) and info.data.get("tags").manual_test is not None: # type: ignore
691
- # Detections that are manual_test MAY have detections, but it is not required. If they
692
- # do not have one, then create one which will be a placeholder.
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 failure for the detection
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 False
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] = ["name", "search"],
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
- summary_dict[field] = getattr(self, field)
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
- }
@@ -29,6 +29,7 @@ import pathlib
29
29
  NO_FILE_NAME = "NO_FILE_NAME"
30
30
 
31
31
 
32
+ # TODO (#266): disable the use_enum_values configuration
32
33
  class SecurityContentObject_Abstract(BaseModel, abc.ABC):
33
34
  model_config = ConfigDict(use_enum_values=True, validate_default=True)
34
35
 
@@ -13,6 +13,7 @@ class TestType(str, Enum):
13
13
  """
14
14
  UNIT = "unit"
15
15
  INTEGRATION = "integration"
16
+ MANUAL = "manual"
16
17
 
17
18
  def __str__(self) -> str:
18
19
  return self.value
@@ -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):
@@ -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 "All"
690
+ return DetectionTestingMode.all.value
676
691
  elif isinstance(self.mode, Changes):
677
- return "Changes"
692
+ return DetectionTestingMode.changes.value
678
693
  else:
679
- return "Selected"
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: Union[None, IntegrationTestResult] = None
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: Optional[int] = None
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
+ )
@@ -0,0 +1,8 @@
1
+ from contentctl.objects.base_test_result import BaseTestResult
2
+
3
+
4
+ class ManualTestResult(BaseTestResult):
5
+ """
6
+ A manual test result
7
+ """
8
+ pass
@@ -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(...)