From 723bfbb27f42bf466b4957b679e5258f41e26dfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20MIGUEL?= Date: Mon, 15 Jun 2026 14:34:27 +0200 Subject: [PATCH] fix(sentinelone): handle missing signature data gracefully in matching When a threat lacks data for a required signature type (e.g. static threats without Deep Visibility have no parent_process_name), the matching now skips that check and relies on remaining signatures instead of silently failing via KeyError. This fixes the detection gap for static threats (e.g. ART droppers blocked during download) where S1 detects and blocks the payload but the collector couldn't match it because: 1. Static threats need DV for parent_process_name (disabled by default) 2. Missing key caused KeyError caught by generic except -> False Also: - Added matched_count guard: at least one signature must be verified - Improved error logging with exc_info for debugging - Removed leftover breakpoint() comments - Added 2 regression tests for the fix Refs: #415 --- .../src/services/expectation_service.py | 34 +++++-- .../services/test_expectation_service.py | 88 +++++++++++++++++++ 2 files changed, 117 insertions(+), 5 deletions(-) diff --git a/sentinelone/src/services/expectation_service.py b/sentinelone/src/services/expectation_service.py index 51d29c29..b082bd16 100644 --- a/sentinelone/src/services/expectation_service.py +++ b/sentinelone/src/services/expectation_service.py @@ -465,7 +465,6 @@ def _match_threats_to_expectations( traces.append(trace) if isinstance(expectation, PreventionExpectation): - # breakpoint() if threat.is_mitigated: matched = True self.logger.debug( @@ -570,7 +569,6 @@ def _expectation_matches_threat_data( ) if oaev_implant_names: - # breakpoint() oaev_data["parent_process_name"] = { "type": "fuzzy", "data": oaev_implant_names, @@ -608,7 +606,19 @@ def _expectation_matches_threat_data( f"{LOG_PREFIX} Filtered OAEV data: {list(filtered_oaev_data.keys())}" ) + matched_count = 0 + skipped_count = 0 for sig_type, signatures in signature_groups.items(): + if sig_type not in filtered_oaev_data: + self.logger.debug( + f"{LOG_PREFIX} Expectation {expectation.inject_expectation_id} requires " + f"'{sig_type}' but threat {threat.threat_id} has no data for it " + f"(available: {list(filtered_oaev_data.keys())}). " + f"Skipping this signature check." + ) + skipped_count += 1 + continue + filtered_data = {sig_type: filtered_oaev_data[sig_type]} self.logger.debug( f"{LOG_PREFIX} Detection helper input - sig_type: {sig_type}" @@ -620,7 +630,6 @@ def _expectation_matches_threat_data( f"{LOG_PREFIX} Detection helper input - filtered_data: {filtered_data}" ) - # breakpoint() match_result = detection_helper.match_alert_elements( signatures, filtered_data ) @@ -635,13 +644,28 @@ def _expectation_matches_threat_data( ) return False + matched_count += 1 + + if matched_count == 0: + self.logger.debug( + f"{LOG_PREFIX} No signature types could be verified for expectation " + f"{expectation.inject_expectation_id} vs threat {threat.threat_id} " + f"(all {len(signature_groups)} signature types had no available data)" + ) + return False + self.logger.debug( - f"{LOG_PREFIX} All signatures matched for expectation {expectation.inject_expectation_id} vs threat {threat.threat_id}" + f"{LOG_PREFIX} Expectation {expectation.inject_expectation_id} matched threat " + f"{threat.threat_id}: {matched_count} verified, {skipped_count} skipped (no data)" ) return True except Exception as e: - self.logger.warning(f"{LOG_PREFIX} Error in expectation matching: {e}") + self.logger.warning( + f"{LOG_PREFIX} Error in expectation matching for " + f"{expectation.inject_expectation_id} vs {threat.threat_id}: {e}", + exc_info=True, + ) return False def _create_error_result_object( diff --git a/sentinelone/tests/services/test_expectation_service.py b/sentinelone/tests/services/test_expectation_service.py index a7ba6a17..d9c7eec6 100644 --- a/sentinelone/tests/services/test_expectation_service.py +++ b/sentinelone/tests/services/test_expectation_service.py @@ -187,6 +187,94 @@ def test_match_threats_to_expectations(): _then_match_succeeds_without_mitigation_requirement(matches) +# Scenario: Static threat matches on hostname when parent_process_name unavailable +def test_static_threat_matches_on_hostname_without_process_name(): + """Scenario: A static threat (dropper) should match on hostname even without DV events. + + Regression test for #415: when DV is disabled and a static threat has no events, + parent_process_name data is unavailable. The matcher should gracefully skip that + signature check and match on the available hostname signature instead of failing. + """ + # Given: An initialized expectation service + service = _given_initialized_expectation_service() + + # Given: A static threat that matches the hostname but has no events + threat = SentinelOneThreat( + threat_id="static_dropper_1", + hostname="target-host.example.com", + is_mitigated=False, + is_static=True, + sha1="deadbeef" * 5, + ) + + # Given: An expectation with both hostname AND parent_process_name signatures + hostname_sig = _create_mock_signature( + SignatureTypes.SIG_TYPE_TARGET_HOSTNAME_ADDRESS, "target-host.example.com" + ) + process_sig = _create_mock_signature( + SignatureTypes.SIG_TYPE_PARENT_PROCESS_NAME, "oaev-implant-test.exe" + ) + expectation = _create_mock_expectation( + expectation_id="static_detection_1", + signatures=[hostname_sig, process_sig], + ) + + # Given: A detection helper that validates hostname matching + detection_helper = Mock() + detection_helper.match_alert_elements = Mock(return_value=True) + + # When: I check if the expectation matches the threat (with no events) + result = service._expectation_matches_threat_data( + expectation, threat, [], detection_helper + ) + + # Then: The match should succeed based on hostname alone + assert result is True # noqa: S101 + # Then: detection_helper was called for hostname but not parent_process_name + detection_helper.match_alert_elements.assert_called_once() + + +# Scenario: No match when ALL signature data is unavailable +def test_no_match_when_all_signature_data_unavailable(): + """Scenario: If no signature type can be verified, the match should fail. + + Ensures we don't produce false positives when the threat provides + zero data matching any expected signature type. + """ + # Given: An initialized expectation service + service = _given_initialized_expectation_service() + + # Given: A threat with NO hostname (None) + threat = SentinelOneThreat( + threat_id="unknown_threat_1", + hostname=None, + is_mitigated=False, + is_static=True, + ) + + # Given: An expectation requiring parent_process_name (no hostname sig) + process_sig = _create_mock_signature( + SignatureTypes.SIG_TYPE_PARENT_PROCESS_NAME, "oaev-implant-test.exe" + ) + expectation = _create_mock_expectation( + expectation_id="no_data_test_1", + signatures=[process_sig], + ) + + # Given: A detection helper + detection_helper = Mock() + + # When: I check if the expectation matches the threat (no events, no hostname) + result = service._expectation_matches_threat_data( + expectation, threat, [], detection_helper + ) + + # Then: The match should fail (no signature could be verified) + assert result is False # noqa: S101 + # Then: detection_helper was never called (no data to match against) + detection_helper.match_alert_elements.assert_not_called() + + # -------- # Given Methods # --------