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 # --------