contentctl 5.3.2__tar.gz → 5.4.1__tar.gz
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-5.3.2 → contentctl-5.4.1}/PKG-INFO +2 -2
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/validate.py +19 -12
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/input/director.py +128 -11
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/abstract_security_content_objects/security_content_object_abstract.py +37 -16
- contentctl-5.4.1/contentctl/objects/base_security_event.py +28 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/correlation_search.py +161 -16
- contentctl-5.4.1/contentctl/objects/notable_event.py +12 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/risk_event.py +3 -19
- {contentctl-5.3.2 → contentctl-5.4.1}/pyproject.toml +2 -2
- contentctl-5.3.2/contentctl/objects/notable_event.py +0 -19
- {contentctl-5.3.2 → contentctl-5.4.1}/LICENSE.md +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/README.md +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/__init__.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/build.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/deploy_acs.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/detection_testing/DetectionTestingManager.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/detection_testing/GitService.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/detection_testing/generate_detection_coverage_badge.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructure.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureContainer.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/detection_testing/infrastructures/DetectionTestingInfrastructureServer.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/detection_testing/progress_bar.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/detection_testing/views/DetectionTestingView.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/detection_testing/views/DetectionTestingViewCLI.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/detection_testing/views/DetectionTestingViewFile.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/detection_testing/views/DetectionTestingViewWeb.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/doc_gen.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/initialize.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/inspect.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/new_content.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/release_notes.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/reporting.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/actions/test.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/api.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/contentctl.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/enrichments/attack_enrichment.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/enrichments/cve_enrichment.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/enrichments/splunk_app_enrichment.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/helper/link_validator.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/helper/logger.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/helper/splunk_app.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/helper/utils.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/input/new_content_questions.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/input/yml_reader.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/abstract_security_content_objects/detection_abstract.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/alert_action.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/annotated_types.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/atomic.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/base_test.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/base_test_result.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/baseline.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/baseline_tags.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/config.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/constants.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/content_versioning_service.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/dashboard.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/data_source.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/deployment.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/deployment_email.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/deployment_notable.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/deployment_phantom.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/deployment_rba.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/deployment_scheduling.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/deployment_slack.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/detection.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/detection_metadata.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/detection_stanza.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/detection_tags.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/drilldown.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/enums.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/errors.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/integration_test.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/integration_test_result.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/investigation.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/investigation_tags.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/lookup.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/macro.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/manual_test.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/manual_test_result.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/mitre_attack_enrichment.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/notable_action.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/playbook.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/playbook_tags.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/rba.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/removed_security_content_object.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/risk_analysis_action.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/risk_object.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/savedsearches_conf.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/security_content_object.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/story.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/story_tags.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/test_attack_data.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/test_group.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/threat_object.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/throttling.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/unit_test.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/unit_test_baseline.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/objects/unit_test_result.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/api_json_output.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/attack_nav_output.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/attack_nav_writer.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/conf_output.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/conf_writer.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/doc_md_output.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/jinja_writer.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/json_writer.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/runtime_csv_writer.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/svg_output.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/analyticstories_detections.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/analyticstories_investigations.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/analyticstories_stories.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/app.conf.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/app.manifest.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/collections.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/content-version.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/detection_count.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/detection_coverage.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/doc_detection_page.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/doc_detections.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/doc_navigation.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/doc_navigation_pages.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/doc_playbooks.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/doc_playbooks_page.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/doc_stories.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/doc_story_page.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/es_investigations_investigations.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/es_investigations_stories.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/header.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/macros.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/panel.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/savedsearches_baselines.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/savedsearches_detections.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/savedsearches_investigations.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/server.conf.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/transforms.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/templates/workflow_actions.j2 +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/output/yml_writer.py +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/README.md +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/app_default.yml +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/app_template/README/essoc_story_detail.txt +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/app_template/README/essoc_summary.txt +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/app_template/README/essoc_usage_dashboard.txt +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/app_template/README.md +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/app_template/default/analytic_stories.conf +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/app_template/default/commands.conf +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/app_template/default/data/ui/nav/default.xml +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/app_template/default/data/ui/views/escu_summary.xml +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/app_template/default/data/ui/views/feedback.xml +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/app_template/default/use_case_library.conf +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/app_template/lookups/mitre_enrichment.csv +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/app_template/metadata/default.meta +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/app_template/static/appIcon.png +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/app_template/static/appIconAlt.png +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/app_template/static/appIconAlt_2x.png +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/app_template/static/appIcon_2x.png +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/data_sources/sysmon_eventid_1.yml +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/datamodels_cim.conf +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/datamodels_custom.conf +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/deployments/escu_default_configuration_anomaly.yml +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/deployments/escu_default_configuration_baseline.yml +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/deployments/escu_default_configuration_correlation.yml +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/deployments/escu_default_configuration_hunting.yml +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/deployments/escu_default_configuration_ttp.yml +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/detections/application/.gitkeep +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/detections/cloud/.gitkeep +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/detections/endpoint/anomalous_usage_of_7zip.yml +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/detections/network/.gitkeep +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/detections/web/.gitkeep +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/macros/security_content_ctime.yml +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/macros/security_content_summariesonly.yml +0 -0
- {contentctl-5.3.2 → contentctl-5.4.1}/contentctl/templates/stories/cobalt_strike.yml +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: contentctl
|
|
3
|
-
Version: 5.
|
|
3
|
+
Version: 5.4.1
|
|
4
4
|
Summary: Splunk Content Control Tool
|
|
5
5
|
License: Apache 2.0
|
|
6
6
|
Author: STRT
|
|
@@ -24,7 +24,7 @@ Requires-Dist: questionary (>=2.0.1,<3.0.0)
|
|
|
24
24
|
Requires-Dist: requests (>=2.32.3,<2.33.0)
|
|
25
25
|
Requires-Dist: rich (>=14.0.0,<15.0.0)
|
|
26
26
|
Requires-Dist: semantic-version (>=2.10.0,<3.0.0)
|
|
27
|
-
Requires-Dist: setuptools (>=69.5.1,<
|
|
27
|
+
Requires-Dist: setuptools (>=69.5.1,<81.0.0)
|
|
28
28
|
Requires-Dist: splunk-sdk (>=2.0.2,<3.0.0)
|
|
29
29
|
Requires-Dist: tqdm (>=4.66.5,<5.0.0)
|
|
30
30
|
Requires-Dist: tyro (>=0.9.2,<0.10.0)
|
|
@@ -4,7 +4,7 @@ from contentctl.enrichments.attack_enrichment import AttackEnrichment
|
|
|
4
4
|
from contentctl.enrichments.cve_enrichment import CveEnrichment
|
|
5
5
|
from contentctl.helper.splunk_app import SplunkApp
|
|
6
6
|
from contentctl.helper.utils import Utils
|
|
7
|
-
from contentctl.input.director import Director, DirectorOutputDto
|
|
7
|
+
from contentctl.input.director import Director, DirectorOutputDto, ValidationFailedError
|
|
8
8
|
from contentctl.objects.atomic import AtomicEnrichment
|
|
9
9
|
from contentctl.objects.config import validate
|
|
10
10
|
from contentctl.objects.data_source import DataSource
|
|
@@ -13,19 +13,26 @@ from contentctl.objects.lookup import FileBackedLookup, RuntimeCSV
|
|
|
13
13
|
|
|
14
14
|
class Validate:
|
|
15
15
|
def execute(self, input_dto: validate) -> DirectorOutputDto:
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
try:
|
|
17
|
+
director_output_dto = DirectorOutputDto(
|
|
18
|
+
AtomicEnrichment.getAtomicEnrichment(input_dto),
|
|
19
|
+
AttackEnrichment.getAttackEnrichment(input_dto),
|
|
20
|
+
CveEnrichment.getCveEnrichment(input_dto),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
director = Director(director_output_dto)
|
|
24
|
+
director.execute(input_dto)
|
|
25
|
+
self.ensure_no_orphaned_files_in_lookups(
|
|
26
|
+
input_dto.path, director_output_dto
|
|
27
|
+
)
|
|
28
|
+
if input_dto.data_source_TA_validation:
|
|
29
|
+
self.validate_latest_TA_information(director_output_dto.data_sources)
|
|
21
30
|
|
|
22
|
-
|
|
23
|
-
director.execute(input_dto)
|
|
24
|
-
self.ensure_no_orphaned_files_in_lookups(input_dto.path, director_output_dto)
|
|
25
|
-
if input_dto.data_source_TA_validation:
|
|
26
|
-
self.validate_latest_TA_information(director_output_dto.data_sources)
|
|
31
|
+
return director_output_dto
|
|
27
32
|
|
|
28
|
-
|
|
33
|
+
except ValidationFailedError:
|
|
34
|
+
# Just re-raise without additional output since we already formatted everything
|
|
35
|
+
raise SystemExit(1)
|
|
29
36
|
|
|
30
37
|
def ensure_no_orphaned_files_in_lookups(
|
|
31
38
|
self, repo_path: pathlib.Path, director_output_dto: DirectorOutputDto
|
|
@@ -109,6 +109,40 @@ class DirectorOutputDto:
|
|
|
109
109
|
self.uuid_to_content_map[content.id] = content
|
|
110
110
|
|
|
111
111
|
|
|
112
|
+
class Colors:
|
|
113
|
+
HEADER = "\033[95m"
|
|
114
|
+
BLUE = "\033[94m"
|
|
115
|
+
CYAN = "\033[96m"
|
|
116
|
+
GREEN = "\033[92m"
|
|
117
|
+
YELLOW = "\033[93m"
|
|
118
|
+
RED = "\033[91m"
|
|
119
|
+
BOLD = "\033[1m"
|
|
120
|
+
UNDERLINE = "\033[4m"
|
|
121
|
+
END = "\033[0m"
|
|
122
|
+
MAGENTA = "\033[35m"
|
|
123
|
+
BRIGHT_MAGENTA = "\033[95m"
|
|
124
|
+
|
|
125
|
+
# Add fallback symbols for Windows
|
|
126
|
+
CHECK_MARK = "✓" if sys.platform != "win32" else "*"
|
|
127
|
+
WARNING = "⚠️" if sys.platform != "win32" else "!"
|
|
128
|
+
ERROR = "❌" if sys.platform != "win32" else "X"
|
|
129
|
+
ARROW = "🎯" if sys.platform != "win32" else ">"
|
|
130
|
+
TOOLS = "🛠️" if sys.platform != "win32" else "#"
|
|
131
|
+
DOCS = "📚" if sys.platform != "win32" else "?"
|
|
132
|
+
BULB = "💡" if sys.platform != "win32" else "i"
|
|
133
|
+
SEARCH = "🔍" if sys.platform != "win32" else "@"
|
|
134
|
+
SPARKLE = "✨" if sys.platform != "win32" else "*"
|
|
135
|
+
ZAP = "⚡" if sys.platform != "win32" else "!"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ValidationFailedError(Exception):
|
|
139
|
+
"""Custom exception for validation failures that already have formatted output."""
|
|
140
|
+
|
|
141
|
+
def __init__(self, message: str):
|
|
142
|
+
self.message = message
|
|
143
|
+
super().__init__(message)
|
|
144
|
+
|
|
145
|
+
|
|
112
146
|
class Director:
|
|
113
147
|
input_dto: validate
|
|
114
148
|
output_dto: DirectorOutputDto
|
|
@@ -268,18 +302,101 @@ class Director:
|
|
|
268
302
|
end="",
|
|
269
303
|
flush=True,
|
|
270
304
|
)
|
|
271
|
-
print("Done!")
|
|
272
305
|
|
|
273
306
|
if len(validation_errors) > 0:
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
307
|
+
if sys.platform == "win32":
|
|
308
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
309
|
+
|
|
310
|
+
print("\n") # Clean separation
|
|
311
|
+
print(f"{Colors.BOLD}{Colors.BRIGHT_MAGENTA}╔{'═' * 60}╗{Colors.END}")
|
|
312
|
+
print(
|
|
313
|
+
f"{Colors.BOLD}{Colors.BRIGHT_MAGENTA}║{Colors.BLUE}{f'{Colors.SEARCH} Content Validation Summary':^59}{Colors.BRIGHT_MAGENTA}║{Colors.END}"
|
|
279
314
|
)
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
f"The following {len(validation_errors)} error(s) were found during validation:\n\n{errors_string}\n\nVALIDATION FAILED"
|
|
315
|
+
print(f"{Colors.BOLD}{Colors.BRIGHT_MAGENTA}╚{'═' * 60}╝{Colors.END}\n")
|
|
316
|
+
|
|
317
|
+
print(
|
|
318
|
+
f"{Colors.BOLD}{Colors.GREEN}{Colors.SPARKLE} Validation Completed{Colors.END} – Issues detected in {Colors.RED}{Colors.BOLD}{len(validation_errors)}{Colors.END} files.\n"
|
|
285
319
|
)
|
|
320
|
+
|
|
321
|
+
for index, entry in enumerate(validation_errors, 1):
|
|
322
|
+
file_path, error = entry
|
|
323
|
+
width = max(70, len(str(file_path)) + 15)
|
|
324
|
+
|
|
325
|
+
# File header with numbered emoji
|
|
326
|
+
number_emoji = f"{index}️⃣"
|
|
327
|
+
print(f"{Colors.YELLOW}┏{'━' * width}┓{Colors.END}")
|
|
328
|
+
print(
|
|
329
|
+
f"{Colors.YELLOW}┃{Colors.BOLD} {number_emoji} File: {Colors.CYAN}{file_path}{Colors.END}{' ' * (width - len(str(file_path)) - 9)}{Colors.YELLOW}┃{Colors.END}"
|
|
330
|
+
)
|
|
331
|
+
print(f"{Colors.YELLOW}┗{'━' * width}┛{Colors.END}")
|
|
332
|
+
|
|
333
|
+
print(
|
|
334
|
+
f" {Colors.RED}{Colors.BOLD}{Colors.ZAP} Validation Issues:{Colors.END}"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
if isinstance(error, ValidationError):
|
|
338
|
+
for err in error.errors():
|
|
339
|
+
error_msg = err.get("msg", "")
|
|
340
|
+
if "https://errors.pydantic.dev" in error_msg:
|
|
341
|
+
# Unfortunately, this is a catch-all for untyped errors. We will still need to emit this
|
|
342
|
+
# This is harder to read, but the other option is suppressing it which we cannot do as
|
|
343
|
+
# it makes troubleshooting extremelt difficult
|
|
344
|
+
print(
|
|
345
|
+
f" {Colors.RED}{Colors.ERROR} {error_msg}{Colors.END}"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# Clean error categorization
|
|
349
|
+
elif "Field required" in error_msg:
|
|
350
|
+
print(
|
|
351
|
+
f" {Colors.YELLOW}{Colors.WARNING} Field Required: {err.get('loc', [''])[0]}{Colors.END}"
|
|
352
|
+
)
|
|
353
|
+
elif "Input should be" in error_msg:
|
|
354
|
+
print(
|
|
355
|
+
f" {Colors.MAGENTA}{Colors.ARROW} Invalid Value for {err.get('loc', [''])[0]}{Colors.END}"
|
|
356
|
+
)
|
|
357
|
+
if err.get("ctx", {}).get("expected", None) is not None:
|
|
358
|
+
print(
|
|
359
|
+
f" Valid options: {err.get('ctx', {}).get('expected', None)}"
|
|
360
|
+
)
|
|
361
|
+
elif "Extra inputs" in error_msg:
|
|
362
|
+
print(
|
|
363
|
+
f" {Colors.BLUE}{Colors.ERROR} Unexpected Field: {err.get('loc', [''])[0]}{Colors.END}"
|
|
364
|
+
)
|
|
365
|
+
elif "Failed to find" in error_msg:
|
|
366
|
+
print(
|
|
367
|
+
f" {Colors.RED}{Colors.SEARCH} Missing Reference: {error_msg}{Colors.END}"
|
|
368
|
+
)
|
|
369
|
+
else:
|
|
370
|
+
print(
|
|
371
|
+
f" {Colors.RED}{Colors.ERROR} {error_msg}{Colors.END}"
|
|
372
|
+
)
|
|
373
|
+
else:
|
|
374
|
+
print(f" {Colors.RED}{Colors.ERROR} {str(error)}{Colors.END}")
|
|
375
|
+
print("")
|
|
376
|
+
|
|
377
|
+
# Clean footer with next steps
|
|
378
|
+
max_width = max(60, max(len(str(e[0])) + 15 for e in validation_errors))
|
|
379
|
+
print(f"{Colors.BOLD}{Colors.CYAN}╔{'═' * max_width}╗{Colors.END}")
|
|
380
|
+
print(
|
|
381
|
+
f"{Colors.BOLD}{Colors.CYAN}║{Colors.BLUE}{Colors.ARROW + ' Next Steps':^{max_width - 1}}{Colors.CYAN}║{Colors.END}"
|
|
382
|
+
)
|
|
383
|
+
print(f"{Colors.BOLD}{Colors.CYAN}╚{'═' * max_width}╝{Colors.END}\n")
|
|
384
|
+
|
|
385
|
+
print(
|
|
386
|
+
f"{Colors.GREEN}{Colors.TOOLS} Fix the validation issues in the listed files{Colors.END}"
|
|
387
|
+
)
|
|
388
|
+
print(
|
|
389
|
+
f"{Colors.YELLOW}{Colors.DOCS} Check the documentation: {Colors.UNDERLINE}https://github.com/splunk/contentctl{Colors.END}"
|
|
390
|
+
)
|
|
391
|
+
print(
|
|
392
|
+
f"{Colors.BLUE}{Colors.BULB} Use --verbose for detailed error information{Colors.END}\n"
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
raise ValidationFailedError(
|
|
396
|
+
f"Validation failed with {len(validation_errors)} error(s)"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
# Success case
|
|
400
|
+
print(
|
|
401
|
+
f"\r{f'{contentCartegoryName} Progress'.rjust(23)}: [{progress_percent:3.0f}%]... {Colors.GREEN}{Colors.CHECK_MARK} Done!{Colors.END}"
|
|
402
|
+
)
|
|
@@ -12,6 +12,7 @@ import pathlib
|
|
|
12
12
|
import pprint
|
|
13
13
|
import uuid
|
|
14
14
|
from abc import abstractmethod
|
|
15
|
+
from difflib import get_close_matches
|
|
15
16
|
from functools import cached_property
|
|
16
17
|
from typing import List, Optional, Tuple, Union
|
|
17
18
|
|
|
@@ -700,16 +701,18 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
|
700
701
|
def mapNamesToSecurityContentObjects(
|
|
701
702
|
cls, v: list[str], director: Union[DirectorOutputDto, None]
|
|
702
703
|
) -> list[Self]:
|
|
703
|
-
if director is
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
704
|
+
if director is None:
|
|
705
|
+
raise Exception(
|
|
706
|
+
"Direction was 'None' when passed to "
|
|
707
|
+
"'mapNamesToSecurityContentObjects'. This is "
|
|
708
|
+
"an error in the contentctl codebase which must be resolved."
|
|
709
|
+
)
|
|
707
710
|
|
|
708
711
|
mappedObjects: list[Self] = []
|
|
709
712
|
mistyped_objects: list[SecurityContentObject_Abstract] = []
|
|
710
713
|
missing_objects: list[str] = []
|
|
711
714
|
for object_name in v:
|
|
712
|
-
found_object =
|
|
715
|
+
found_object = director.name_to_content_map.get(object_name, None)
|
|
713
716
|
if not found_object:
|
|
714
717
|
missing_objects.append(object_name)
|
|
715
718
|
elif not isinstance(found_object, cls):
|
|
@@ -718,22 +721,40 @@ class SecurityContentObject_Abstract(BaseModel, abc.ABC):
|
|
|
718
721
|
mappedObjects.append(found_object)
|
|
719
722
|
|
|
720
723
|
errors: list[str] = []
|
|
721
|
-
|
|
724
|
+
for missing_object in missing_objects:
|
|
725
|
+
if missing_object.endswith("_filter"):
|
|
726
|
+
# Most filter macros are defined as empty at runtime, so we do not
|
|
727
|
+
# want to make any suggestions. It is time consuming and not helpful
|
|
728
|
+
# to make these suggestions, so we just skip them in this check.
|
|
729
|
+
continue
|
|
730
|
+
matches = get_close_matches(
|
|
731
|
+
missing_object,
|
|
732
|
+
director.name_to_content_map.keys(),
|
|
733
|
+
n=3,
|
|
734
|
+
)
|
|
735
|
+
if matches == []:
|
|
736
|
+
matches = ["NO SUGGESTIONS"]
|
|
737
|
+
|
|
738
|
+
matches_string = ", ".join(matches)
|
|
722
739
|
errors.append(
|
|
723
|
-
f"
|
|
740
|
+
f"Unable to find: {missing_object}\n Suggestions: {matches_string}"
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
for mistyped_object in mistyped_objects:
|
|
744
|
+
matches = get_close_matches(
|
|
745
|
+
mistyped_object.name, director.name_to_content_map.keys(), n=3
|
|
746
|
+
)
|
|
747
|
+
|
|
748
|
+
errors.append(
|
|
749
|
+
f"'{mistyped_object.name}' expected to have type '{cls.__name__}', but actually "
|
|
750
|
+
f"had type '{type(mistyped_object).__name__}'"
|
|
724
751
|
)
|
|
725
|
-
if len(mistyped_objects) > 0:
|
|
726
|
-
for mistyped_object in mistyped_objects:
|
|
727
|
-
errors.append(
|
|
728
|
-
f"'{mistyped_object.name}' expected to have type '{cls}', but actually "
|
|
729
|
-
f"had type '{type(mistyped_object)}'"
|
|
730
|
-
)
|
|
731
752
|
|
|
732
753
|
if len(errors) > 0:
|
|
733
|
-
error_string = "\n - ".join(errors)
|
|
754
|
+
error_string = "\n\n - ".join(errors)
|
|
734
755
|
raise ValueError(
|
|
735
|
-
f"Found {len(errors)} issues when resolving references
|
|
736
|
-
f"
|
|
756
|
+
f"Found {len(errors)} issues when resolving references to '{cls.__name__}' objects:\n"
|
|
757
|
+
f" - {error_string}"
|
|
737
758
|
)
|
|
738
759
|
|
|
739
760
|
# Sort all objects sorted by name
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, ConfigDict
|
|
4
|
+
|
|
5
|
+
from contentctl.objects.detection import Detection
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseSecurityEvent(BaseModel, ABC):
|
|
9
|
+
"""
|
|
10
|
+
Base event class for a Splunk security event (e.g. risks and notables)
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
# The search name (e.g. "ESCU - Windows Modify Registry EnableLinkedConnections - Rule")
|
|
14
|
+
search_name: str
|
|
15
|
+
|
|
16
|
+
# The search ID that found that generated this event
|
|
17
|
+
orig_sid: str
|
|
18
|
+
|
|
19
|
+
# Allowing fields that aren't explicitly defined to be passed since some of the risk/notable
|
|
20
|
+
# event's fields vary depending on the SPL which generated them
|
|
21
|
+
model_config = ConfigDict(extra="allow")
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def validate_against_detection(self, detection: Detection) -> None:
|
|
25
|
+
"""
|
|
26
|
+
Validate this risk/notable event against the given detection
|
|
27
|
+
"""
|
|
28
|
+
raise NotImplementedError()
|
|
@@ -18,6 +18,7 @@ from contentctl.actions.detection_testing.progress_bar import (
|
|
|
18
18
|
format_pbar_string, # type: ignore
|
|
19
19
|
)
|
|
20
20
|
from contentctl.helper.utils import Utils
|
|
21
|
+
from contentctl.objects.base_security_event import BaseSecurityEvent
|
|
21
22
|
from contentctl.objects.base_test_result import TestResultStatus
|
|
22
23
|
from contentctl.objects.detection import Detection
|
|
23
24
|
from contentctl.objects.errors import (
|
|
@@ -222,6 +223,9 @@ class CorrelationSearch(BaseModel):
|
|
|
222
223
|
# The list of risk events found
|
|
223
224
|
_risk_events: list[RiskEvent] | None = PrivateAttr(default=None)
|
|
224
225
|
|
|
226
|
+
# The list of risk data model events found
|
|
227
|
+
_risk_dm_events: list[BaseSecurityEvent] | None = PrivateAttr(default=None)
|
|
228
|
+
|
|
225
229
|
# The list of notable events found
|
|
226
230
|
_notable_events: list[NotableEvent] | None = PrivateAttr(default=None)
|
|
227
231
|
|
|
@@ -554,6 +558,13 @@ class CorrelationSearch(BaseModel):
|
|
|
554
558
|
raise
|
|
555
559
|
events.append(event)
|
|
556
560
|
self.logger.debug(f"Found risk event for '{self.name}': {event}")
|
|
561
|
+
else:
|
|
562
|
+
msg = (
|
|
563
|
+
f"Found event for unexpected index ({result['index']}) in our query "
|
|
564
|
+
f"results (expected {Indexes.RISK_INDEX})"
|
|
565
|
+
)
|
|
566
|
+
self.logger.error(msg)
|
|
567
|
+
raise ValueError(msg)
|
|
557
568
|
except ServerError as e:
|
|
558
569
|
self.logger.error(f"Error returned from Splunk instance: {e}")
|
|
559
570
|
raise e
|
|
@@ -623,6 +634,13 @@ class CorrelationSearch(BaseModel):
|
|
|
623
634
|
raise
|
|
624
635
|
events.append(event)
|
|
625
636
|
self.logger.debug(f"Found notable event for '{self.name}': {event}")
|
|
637
|
+
else:
|
|
638
|
+
msg = (
|
|
639
|
+
f"Found event for unexpected index ({result['index']}) in our query "
|
|
640
|
+
f"results (expected {Indexes.NOTABLE_INDEX})"
|
|
641
|
+
)
|
|
642
|
+
self.logger.error(msg)
|
|
643
|
+
raise ValueError(msg)
|
|
626
644
|
except ServerError as e:
|
|
627
645
|
self.logger.error(f"Error returned from Splunk instance: {e}")
|
|
628
646
|
raise e
|
|
@@ -637,15 +655,119 @@ class CorrelationSearch(BaseModel):
|
|
|
637
655
|
|
|
638
656
|
return events
|
|
639
657
|
|
|
658
|
+
def risk_dm_event_exists(self) -> bool:
|
|
659
|
+
"""Whether at least one matching risk data model event exists
|
|
660
|
+
|
|
661
|
+
Queries the `risk` data model and returns True if at least one matching event (could come
|
|
662
|
+
from risk or notable index) exists for this search
|
|
663
|
+
:return: a bool indicating whether a risk data model event for this search exists in the
|
|
664
|
+
risk data model
|
|
665
|
+
"""
|
|
666
|
+
# We always force an update on the cache when checking if events exist
|
|
667
|
+
events = self.get_risk_dm_events(force_update=True)
|
|
668
|
+
return len(events) > 0
|
|
669
|
+
|
|
670
|
+
def get_risk_dm_events(self, force_update: bool = False) -> list[BaseSecurityEvent]:
|
|
671
|
+
"""Get risk data model events from the Splunk instance
|
|
672
|
+
|
|
673
|
+
Queries the `risk` data model and returns any matching events (could come from risk or
|
|
674
|
+
notable index)
|
|
675
|
+
:param force_update: whether the cached _risk_events should be forcibly updated if already
|
|
676
|
+
set
|
|
677
|
+
:return: a list of risk events
|
|
678
|
+
"""
|
|
679
|
+
# Reset the list of risk data model events if we're forcing an update
|
|
680
|
+
if force_update:
|
|
681
|
+
self.logger.debug("Resetting risk data model event cache.")
|
|
682
|
+
self._risk_dm_events = None
|
|
683
|
+
|
|
684
|
+
# Use the cached risk_dm_events unless we're forcing an update
|
|
685
|
+
if self._risk_dm_events is not None:
|
|
686
|
+
self.logger.debug(
|
|
687
|
+
f"Using cached risk data model events ({len(self._risk_dm_events)} total)."
|
|
688
|
+
)
|
|
689
|
+
return self._risk_dm_events
|
|
690
|
+
|
|
691
|
+
# TODO (#248): Refactor risk/notable querying to pin to a single savedsearch ID
|
|
692
|
+
# Search for all risk data model events from a single scheduled search (indicated by
|
|
693
|
+
# orig_sid)
|
|
694
|
+
query = (
|
|
695
|
+
f'datamodel Risk All_Risk flat | search search_name="{self.name}" [datamodel Risk '
|
|
696
|
+
f'All_Risk flat | search search_name="{self.name}" | tail 1 | fields orig_sid] '
|
|
697
|
+
"| tojson"
|
|
698
|
+
)
|
|
699
|
+
result_iterator = self._search(query)
|
|
700
|
+
|
|
701
|
+
# Iterate over the events, storing them in a list and checking for any errors
|
|
702
|
+
events: list[BaseSecurityEvent] = []
|
|
703
|
+
risk_count = 0
|
|
704
|
+
notable_count = 0
|
|
705
|
+
try:
|
|
706
|
+
for result in result_iterator:
|
|
707
|
+
# sanity check that this result from the iterator is a risk event and not some
|
|
708
|
+
# other metadata
|
|
709
|
+
if result["index"] == Indexes.RISK_INDEX:
|
|
710
|
+
try:
|
|
711
|
+
parsed_raw = json.loads(result["_raw"])
|
|
712
|
+
event = RiskEvent.model_validate(parsed_raw)
|
|
713
|
+
except Exception:
|
|
714
|
+
self.logger.error(
|
|
715
|
+
f"Failed to parse RiskEvent from search result: {result}"
|
|
716
|
+
)
|
|
717
|
+
raise
|
|
718
|
+
events.append(event)
|
|
719
|
+
risk_count += 1
|
|
720
|
+
self.logger.debug(
|
|
721
|
+
f"Found risk event in risk data model for '{self.name}': {event}"
|
|
722
|
+
)
|
|
723
|
+
elif result["index"] == Indexes.NOTABLE_INDEX:
|
|
724
|
+
try:
|
|
725
|
+
parsed_raw = json.loads(result["_raw"])
|
|
726
|
+
event = NotableEvent.model_validate(parsed_raw)
|
|
727
|
+
except Exception:
|
|
728
|
+
self.logger.error(
|
|
729
|
+
f"Failed to parse NotableEvent from search result: {result}"
|
|
730
|
+
)
|
|
731
|
+
raise
|
|
732
|
+
events.append(event)
|
|
733
|
+
notable_count += 1
|
|
734
|
+
self.logger.debug(
|
|
735
|
+
f"Found notable event in risk data model for '{self.name}': {event}"
|
|
736
|
+
)
|
|
737
|
+
else:
|
|
738
|
+
msg = (
|
|
739
|
+
f"Found event for unexpected index ({result['index']}) in our query "
|
|
740
|
+
f"results (expected {Indexes.NOTABLE_INDEX} or {Indexes.RISK_INDEX})"
|
|
741
|
+
)
|
|
742
|
+
self.logger.error(msg)
|
|
743
|
+
raise ValueError(msg)
|
|
744
|
+
except ServerError as e:
|
|
745
|
+
self.logger.error(f"Error returned from Splunk instance: {e}")
|
|
746
|
+
raise e
|
|
747
|
+
|
|
748
|
+
# Log if no events were found
|
|
749
|
+
if len(events) < 1:
|
|
750
|
+
self.logger.debug(f"No events found in risk data model for '{self.name}'")
|
|
751
|
+
else:
|
|
752
|
+
# Set the cache if we found events
|
|
753
|
+
self._risk_dm_events = events
|
|
754
|
+
self.logger.debug(
|
|
755
|
+
f"Caching {len(self._risk_dm_events)} risk data model events."
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
# Log counts of risk and notable events found
|
|
759
|
+
self.logger.debug(
|
|
760
|
+
f"Found {risk_count} risk events and {notable_count} notable events in the risk data "
|
|
761
|
+
"model"
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
return events
|
|
765
|
+
|
|
640
766
|
def validate_risk_events(self) -> None:
|
|
641
767
|
"""Validates the existence of any expected risk events
|
|
642
768
|
|
|
643
769
|
First ensure the risk event exists, and if it does validate its risk message and make sure
|
|
644
|
-
any events align with the specified risk object.
|
|
645
|
-
if risk events existed
|
|
646
|
-
:param elapsed_sleep_time: an int representing the amount of time slept thus far waiting to
|
|
647
|
-
check the risks/notables
|
|
648
|
-
:returns: an IntegrationTestResult on failure; None on success
|
|
770
|
+
any events align with the specified risk object.
|
|
649
771
|
"""
|
|
650
772
|
# Ensure the rba object is defined
|
|
651
773
|
if self.detection.rba is None:
|
|
@@ -735,13 +857,29 @@ class CorrelationSearch(BaseModel):
|
|
|
735
857
|
def validate_notable_events(self) -> None:
|
|
736
858
|
"""Validates the existence of any expected notables
|
|
737
859
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
:
|
|
860
|
+
Check various fields within the notable to ensure alignment with the detection definition.
|
|
861
|
+
Additionally, ensure that the notable does not appear in the risk data model, as this is
|
|
862
|
+
currently undesired behavior for ESCU detections.
|
|
863
|
+
"""
|
|
864
|
+
if self.notable_in_risk_dm():
|
|
865
|
+
raise ValidationFailed(
|
|
866
|
+
"One or more notables appeared in the risk data model. This could lead to risk "
|
|
867
|
+
"score doubling, and/or notable multiplexing, depending on the detection type "
|
|
868
|
+
"(e.g. TTP), or the number of risk modifiers."
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
def notable_in_risk_dm(self) -> bool:
|
|
872
|
+
"""Check if notables are in the risk data model
|
|
873
|
+
|
|
874
|
+
Returns a bool indicating whether notables are in the risk data model or not.
|
|
875
|
+
|
|
876
|
+
:returns: a bool, True if notables are in the risk data model results; False if not
|
|
743
877
|
"""
|
|
744
|
-
|
|
878
|
+
if self.risk_dm_event_exists():
|
|
879
|
+
for event in self.get_risk_dm_events():
|
|
880
|
+
if isinstance(event, NotableEvent):
|
|
881
|
+
return True
|
|
882
|
+
return False
|
|
745
883
|
|
|
746
884
|
# NOTE: it would be more ideal to switch this to a system which gets the handle of the saved search job and polls
|
|
747
885
|
# it for completion, but that seems more tricky
|
|
@@ -828,8 +966,8 @@ class CorrelationSearch(BaseModel):
|
|
|
828
966
|
|
|
829
967
|
try:
|
|
830
968
|
# Validate risk events
|
|
831
|
-
self.logger.debug("Checking for matching risk events")
|
|
832
969
|
if self.has_risk_analysis_action:
|
|
970
|
+
self.logger.debug("Checking for matching risk events")
|
|
833
971
|
if self.risk_event_exists():
|
|
834
972
|
# TODO (PEX-435): should this in the retry loop? or outside it?
|
|
835
973
|
# -> I've observed there being a missing risk event (15/16) on
|
|
@@ -846,22 +984,28 @@ class CorrelationSearch(BaseModel):
|
|
|
846
984
|
raise ValidationFailed(
|
|
847
985
|
f"TEST FAILED: No matching risk event created for: {self.name}"
|
|
848
986
|
)
|
|
987
|
+
else:
|
|
988
|
+
self.logger.debug(
|
|
989
|
+
f"No risk action defined for '{self.name}'"
|
|
990
|
+
)
|
|
849
991
|
|
|
850
992
|
# Validate notable events
|
|
851
|
-
self.logger.debug("Checking for matching notable events")
|
|
852
993
|
if self.has_notable_action:
|
|
994
|
+
self.logger.debug("Checking for matching notable events")
|
|
853
995
|
# NOTE: because we check this last, if both fail, the error message about notables will
|
|
854
996
|
# always be the last to be added and thus the one surfaced to the user
|
|
855
997
|
if self.notable_event_exists():
|
|
856
998
|
# TODO (PEX-435): should this in the retry loop? or outside it?
|
|
857
|
-
|
|
858
|
-
# commented out below is unimplemented)
|
|
859
|
-
# self.validate_notable_events(elapsed_sleep_time)
|
|
999
|
+
self.validate_notable_events()
|
|
860
1000
|
pass
|
|
861
1001
|
else:
|
|
862
1002
|
raise ValidationFailed(
|
|
863
1003
|
f"TEST FAILED: No matching notable event created for: {self.name}"
|
|
864
1004
|
)
|
|
1005
|
+
else:
|
|
1006
|
+
self.logger.debug(
|
|
1007
|
+
f"No notable action defined for '{self.name}'"
|
|
1008
|
+
)
|
|
865
1009
|
except ValidationFailed as e:
|
|
866
1010
|
self.logger.error(f"Risk/notable validation failed: {e}")
|
|
867
1011
|
result = IntegrationTestResult(
|
|
@@ -1015,6 +1159,7 @@ class CorrelationSearch(BaseModel):
|
|
|
1015
1159
|
# reset caches
|
|
1016
1160
|
self._risk_events = None
|
|
1017
1161
|
self._notable_events = None
|
|
1162
|
+
self._risk_dm_events = None
|
|
1018
1163
|
|
|
1019
1164
|
def update_pbar(self, state: str) -> str:
|
|
1020
1165
|
"""
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from contentctl.objects.base_security_event import BaseSecurityEvent
|
|
2
|
+
from contentctl.objects.detection import Detection
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class NotableEvent(BaseSecurityEvent):
|
|
6
|
+
# TODO (PEX-434): implement deeper notable validation
|
|
7
|
+
|
|
8
|
+
def validate_against_detection(self, detection: Detection) -> None:
|
|
9
|
+
"""
|
|
10
|
+
Validate this risk/notable event against the given detection
|
|
11
|
+
"""
|
|
12
|
+
raise NotImplementedError()
|
|
@@ -1,26 +1,17 @@
|
|
|
1
1
|
import re
|
|
2
2
|
from functools import cached_property
|
|
3
3
|
|
|
4
|
-
from pydantic import
|
|
5
|
-
BaseModel,
|
|
6
|
-
ConfigDict,
|
|
7
|
-
Field,
|
|
8
|
-
PrivateAttr,
|
|
9
|
-
computed_field,
|
|
10
|
-
field_validator,
|
|
11
|
-
)
|
|
4
|
+
from pydantic import Field, PrivateAttr, computed_field, field_validator
|
|
12
5
|
|
|
6
|
+
from contentctl.objects.base_security_event import BaseSecurityEvent
|
|
13
7
|
from contentctl.objects.detection import Detection
|
|
14
8
|
from contentctl.objects.errors import ValidationFailed
|
|
15
9
|
from contentctl.objects.rba import RiskObject
|
|
16
10
|
|
|
17
11
|
|
|
18
|
-
class RiskEvent(
|
|
12
|
+
class RiskEvent(BaseSecurityEvent):
|
|
19
13
|
"""Model for risk event in ES"""
|
|
20
14
|
|
|
21
|
-
# The search name (e.g. "ESCU - Windows Modify Registry EnableLinkedConnections - Rule")
|
|
22
|
-
search_name: str
|
|
23
|
-
|
|
24
15
|
# The subject of the risk event (e.g. a username, process name, system name, account ID, etc.)
|
|
25
16
|
# (not to be confused w/ the risk object from the detection)
|
|
26
17
|
es_risk_object: int | str = Field(alias="risk_object")
|
|
@@ -32,9 +23,6 @@ class RiskEvent(BaseModel):
|
|
|
32
23
|
# The level of risk associated w/ the risk event
|
|
33
24
|
risk_score: int
|
|
34
25
|
|
|
35
|
-
# The search ID that found that generated this risk event
|
|
36
|
-
orig_sid: str
|
|
37
|
-
|
|
38
26
|
# The message for the risk event
|
|
39
27
|
risk_message: str
|
|
40
28
|
|
|
@@ -53,10 +41,6 @@ class RiskEvent(BaseModel):
|
|
|
53
41
|
# Private attribute caching the risk object this RiskEvent is mapped to
|
|
54
42
|
_matched_risk_object: RiskObject | None = PrivateAttr(default=None)
|
|
55
43
|
|
|
56
|
-
# Allowing fields that aren't explicitly defined to be passed since some of the risk event's
|
|
57
|
-
# fields vary depending on the SPL which generated them
|
|
58
|
-
model_config = ConfigDict(extra="allow")
|
|
59
|
-
|
|
60
44
|
@field_validator("annotations_mitre_attack", "analyticstories", mode="before")
|
|
61
45
|
@classmethod
|
|
62
46
|
def _convert_str_value_to_singleton(cls, v: str | list[str]) -> list[str]:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "contentctl"
|
|
3
3
|
|
|
4
|
-
version = "5.
|
|
4
|
+
version = "5.4.1"
|
|
5
5
|
|
|
6
6
|
description = "Splunk Content Control Tool"
|
|
7
7
|
authors = ["STRT <research@splunk.com>"]
|
|
@@ -30,7 +30,7 @@ tqdm = "^4.66.5"
|
|
|
30
30
|
pygit2 = "^1.15.1"
|
|
31
31
|
tyro = "^0.9.2"
|
|
32
32
|
gitpython = "^3.1.43"
|
|
33
|
-
setuptools = ">=69.5.1,<
|
|
33
|
+
setuptools = ">=69.5.1,<81.0.0"
|
|
34
34
|
rich = "^14.0.0"
|
|
35
35
|
|
|
36
36
|
[tool.poetry.group.dev.dependencies]
|