diff --git a/CrowdStrikeFalcon/CHANGELOG.md b/CrowdStrikeFalcon/CHANGELOG.md index 9238cc5c3..31adcf8b1 100644 --- a/CrowdStrikeFalcon/CHANGELOG.md +++ b/CrowdStrikeFalcon/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## 2024-10-11 - 1.21.0 + +### Added + +- Support of Alert API + ## 2024-09-05 - 1.20.0 ### Added diff --git a/CrowdStrikeFalcon/crowdstrike_falcon/client/__init__.py b/CrowdStrikeFalcon/crowdstrike_falcon/client/__init__.py index 048604fdc..f9469f58e 100644 --- a/CrowdStrikeFalcon/crowdstrike_falcon/client/__init__.py +++ b/CrowdStrikeFalcon/crowdstrike_falcon/client/__init__.py @@ -1,7 +1,7 @@ import enum from collections.abc import Generator -from typing import Any from posixpath import join as urljoin +from typing import Any import requests from requests.auth import AuthBase, HTTPBasicAuth @@ -117,6 +117,14 @@ def get_detection_details(self, detection_ids: list[str], **kwargs) -> Generator **kwargs, ) + def get_alert_details(self, composite_ids: list[str], **kwargs) -> Generator[dict, None, None]: + yield from self.request_endpoint( + "POST", + "/alerts/entities/alerts/v2", + json={"composite_ids": composite_ids}, + **kwargs, + ) + def find_indicators(self, fql_filter, **kwargs) -> Generator[dict, None, None]: yield from self.request_endpoint( "GET", diff --git a/CrowdStrikeFalcon/crowdstrike_falcon/custom_iocs.py b/CrowdStrikeFalcon/crowdstrike_falcon/custom_iocs.py index 6e8ab99bf..7bb3c1d7a 100644 --- a/CrowdStrikeFalcon/crowdstrike_falcon/custom_iocs.py +++ b/CrowdStrikeFalcon/crowdstrike_falcon/custom_iocs.py @@ -1,7 +1,7 @@ from collections import defaultdict -from typing import Dict, List -from posixpath import join as urljoin from datetime import date, timedelta +from posixpath import join as urljoin +from typing import Dict, List from crowdstrike_falcon.action import CrowdstrikeAction from crowdstrike_falcon.helpers import stix_to_indicators diff --git a/CrowdStrikeFalcon/crowdstrike_falcon/event_stream_trigger.py b/CrowdStrikeFalcon/crowdstrike_falcon/event_stream_trigger.py index 493c3ef7d..690c64655 100644 --- a/CrowdStrikeFalcon/crowdstrike_falcon/event_stream_trigger.py +++ b/CrowdStrikeFalcon/crowdstrike_falcon/event_stream_trigger.py @@ -6,7 +6,6 @@ from functools import cached_property import orjson -import requests.exceptions from requests.auth import AuthBase from requests.exceptions import HTTPError, StreamConsumedError from sekoia_automation.connector import Connector @@ -16,10 +15,15 @@ from crowdstrike_falcon import CrowdStrikeFalconModule from crowdstrike_falcon.client import CrowdstrikeFalconClient, CrowdstrikeThreatGraphClient from crowdstrike_falcon.exceptions import StreamNotAvailable -from crowdstrike_falcon.helpers import get_detection_id, group_edges_by_verticle_type, compute_refresh_interval +from crowdstrike_falcon.helpers import ( + compute_refresh_interval, + get_detection_id, + get_epp_detection_composite_id, + group_edges_by_verticle_type, +) +from crowdstrike_falcon.logging import get_logger from crowdstrike_falcon.metrics import EVENTS_LAG, INCOMING_DETECTIONS, INCOMING_VERTICLES, OUTCOMING_EVENTS from crowdstrike_falcon.models import CrowdStrikeFalconEventStreamConfiguration -from crowdstrike_falcon.logging import get_logger logger = get_logger() @@ -73,6 +77,29 @@ def get_graph_ids_from_detection(self, detection_details: dict) -> set[str]: return graph_ids + def get_graph_ids_from_alert(self, alert_details: dict) -> set[str]: + """ + Extract graph ids from an alert + + :param dict alert_details: The alert + :return: A set of graph ids extracted from the alert + :rtype: set + """ + # extract graph_ids from the resources + graph_ids = set() + + # Get the triggering process graph id + triggering_process_graph_id = alert_details.get("triggering_process_graph_id") + if triggering_process_graph_id is not None: + graph_ids.add(triggering_process_graph_id) + + # Get the parent process graph id + parent_process_graph_id = alert_details.get("parent_details", {}).get("process_graph_id") + if parent_process_graph_id is not None: + graph_ids.add(parent_process_graph_id) + + return graph_ids + def collect_verticles_from_graph_ids(self, graph_ids: set[str]) -> Generator[tuple[str, str, dict], None, None]: """ Collect verticles from a list of graph ids @@ -131,6 +158,42 @@ def collect_verticles_from_detection(self, detection_id: str) -> Generator[tuple message=f"Failed to collect verticles for detection {detection_id}", ) + def collect_verticles_from_alert(self, composite_id: str) -> Generator[tuple[str, str, dict], None, None]: + """ + Collect the verticles (events) from an alert + + :param str detection_id: The identifier of the alert + """ + try: + # get alert details from its identifier + alert_details = next(self.falcon_client.get_alert_details(composite_ids=[composite_id])) + + # get graph ids from detection + graph_ids = self.get_graph_ids_from_alert(alert_details) + + # for each group, get their verticles + yield from self.collect_verticles_from_graph_ids(graph_ids) + + except HTTPError as error: + if error.response.status_code == 403: + # we don't have proper permissions - roll back to the old API + self.connector.use_alert_api = False + self.log(level="error", message="Not enough permissions to use Alert API - rollback to Detection API") + + self.log_exception( + error, + message=( + f"Failed to collect verticles for alert {composite_id}: " + f"{error.response.status_code} {error.response.reason}" + ), + ) + + except Exception as error: + self.log_exception( + error, + message=f"Failed to collect verticles for alert {composite_id}", + ) + class EventStreamAuthentication(AuthBase): def __init__(self, session_token: str): @@ -263,6 +326,10 @@ def run(self) -> None: intake_key=self.connector.configuration.intake_key ).inc() + if self.connector.use_alert_api: + alert_id = get_epp_detection_composite_id(event) + self.collect_verticles_for_epp_detection(alert_id, event) + detection_id = get_detection_id(event) self.collect_verticles(detection_id, event) @@ -329,6 +396,41 @@ def collect_verticles(self, detection_id: str | None, detection_event: dict): self.log(message=f"Collected {nb_verticles} vertex", level="info") + def collect_verticles_for_epp_detection(self, composite_id: str | None, detection_event: dict): + if composite_id is None: + logger.info("Not a epp detection") + return + + if self.verticles_collector is None: + logger.info("verticles collection disabled") + return + + logger.info("Collect verticles for detection", composite_id=composite_id) + + event_content = detection_event.get("event", {}) + severity_name = event_content.get("SeverityName") + severity_code = event_content.get("Severity") + + nb_verticles = 0 + for ( + source_vertex_id, + edge_type, + vertex, + ) in self.verticles_collector.collect_verticles_from_alert(composite_id): + nb_verticles += 1 + event = { + "metadata": { + "detectionIdString": composite_id, + "eventType": "Vertex", + "edge": {"sourceVertexId": source_vertex_id, "type": edge_type}, + "severity": {"name": severity_name, "code": severity_code}, + }, + "event": vertex, + } + self.events_queue.put((self.stream_root_url, orjson.dumps(event).decode())) + + self.log(message=f"Collected {nb_verticles} vertex", level="info") + class EventForwarder(threading.Thread): def __init__( @@ -430,6 +532,11 @@ def __init__(self, *args, **kwargs) -> None: self._network_sleep_on_retry = 60 + # Detect API will be taken down in favor of the Alert API as well as that + # DetectionSummaryEvent will be replaced by EppDetectionSummaryEvent. By default, + # we'll try to use the new API, but we'll fail if permissions are not set. + self.use_alert_api = True + def generate_app_id(self): return f"sio-{time.time()}" diff --git a/CrowdStrikeFalcon/crowdstrike_falcon/helpers.py b/CrowdStrikeFalcon/crowdstrike_falcon/helpers.py index a3ffeef51..4da6b2940 100644 --- a/CrowdStrikeFalcon/crowdstrike_falcon/helpers.py +++ b/CrowdStrikeFalcon/crowdstrike_falcon/helpers.py @@ -93,6 +93,23 @@ def get_detection_id(event: dict) -> str | None: return event.get("event", {}).get("DetectId") +def get_epp_detection_composite_id(event: dict) -> str | None: + """ + For EPP detection summary event, return the identifier of the detection. + Otherwise, return None + + :param dict event: The event + :return: identifier of the detection if the event is a detection summary event, None otherwise + :rtype: str | None + """ + # Is a epp detection summary event? + event_type = event.get("metadata", {}).get("eventType") + if event_type != "EppDetectionSummaryEvent": + return None + + return event.get("event", {}).get("CompositeId") + + def is_a_supported_stix_indicator(stix_object): # Check if object is an indicator if stix_object.get("type") != "indicator": diff --git a/CrowdStrikeFalcon/manifest.json b/CrowdStrikeFalcon/manifest.json index faa8c30e9..144fd5436 100644 --- a/CrowdStrikeFalcon/manifest.json +++ b/CrowdStrikeFalcon/manifest.json @@ -3,7 +3,7 @@ "name": "CrowdStrike Falcon", "slug": "crowdstrike-falcon", "description": "Integrates with CrowdStrike Falcon EDR", - "version": "1.20.0", + "version": "1.21.0", "configuration": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/CrowdStrikeFalcon/tests/test_actions_hosts.py b/CrowdStrikeFalcon/tests/test_actions_hosts.py index a61d8b213..17d8a736b 100644 --- a/CrowdStrikeFalcon/tests/test_actions_hosts.py +++ b/CrowdStrikeFalcon/tests/test_actions_hosts.py @@ -2,10 +2,7 @@ from crowdstrike_falcon import CrowdStrikeFalconModule from crowdstrike_falcon.action import CrowdstrikeAction -from crowdstrike_falcon.host_actions import ( - CrowdstrikeActionIsolateHosts, - CrowdstrikeActionDeIsolateHosts, -) +from crowdstrike_falcon.host_actions import CrowdstrikeActionDeIsolateHosts, CrowdstrikeActionIsolateHosts def configured_action(action: CrowdstrikeAction): diff --git a/CrowdStrikeFalcon/tests/test_actions_iocs.py b/CrowdStrikeFalcon/tests/test_actions_iocs.py index 23b3107a4..02ccebd08 100644 --- a/CrowdStrikeFalcon/tests/test_actions_iocs.py +++ b/CrowdStrikeFalcon/tests/test_actions_iocs.py @@ -1,8 +1,8 @@ import os +from datetime import date, timedelta import pytest import requests_mock -from datetime import date, timedelta from crowdstrike_falcon import CrowdStrikeFalconModule from crowdstrike_falcon.action import CrowdstrikeAction diff --git a/CrowdStrikeFalcon/tests/test_event_stream_trigger.py b/CrowdStrikeFalcon/tests/test_event_stream_trigger.py index f4f4595ac..2794f5c3c 100644 --- a/CrowdStrikeFalcon/tests/test_event_stream_trigger.py +++ b/CrowdStrikeFalcon/tests/test_event_stream_trigger.py @@ -7,15 +7,16 @@ import orjson import pytest +import requests.exceptions import requests_mock from crowdstrike_falcon import CrowdStrikeFalconModule from crowdstrike_falcon.client import CrowdstrikeFalconClient, CrowdstrikeThreatGraphClient from crowdstrike_falcon.event_stream_trigger import ( + EventForwarder, EventStreamReader, EventStreamTrigger, VerticlesCollector, - EventForwarder, ) @@ -23,6 +24,7 @@ def trigger(symphony_storage): module = CrowdStrikeFalconModule() trigger = EventStreamTrigger(module=module, data_path=symphony_storage) + trigger.use_alert_api = False # mock the log function of trigger that requires network access to the api for reporting trigger.log = MagicMock() trigger.module.configuration = { @@ -460,6 +462,74 @@ def test_verticle_collector_get_graph_ids_from_detection(verticles_collector): } +def test_verticle_collector_get_graph_ids_from_alert(verticles_collector): + alert_details = { + "composite_id": "ad5f82e879a9c5d6b5b442eb37e50551:ind:835449907c99453085a924a16e967be5:17212155109-1-2", + "control_graph_id": "ctg:835449907c99453085a924a16e967be5:17212155109", + "parent_details": { + "cmdline": "/usr/lib/git-core/git fetch --update-head-ok", + "filename": "git", + "filepath": "/usr/lib/git-core/git", + "local_process_id": "1", + "md5": "1bc29b36f623ba82aaf6724fd3b16718", + "process_graph_id": "pid:835449907c99453085a924a16e967be5:27242182487", + "process_id": "1", + "sha256": "5d5b09f6dcb2d53a5fffc60c4ac0d55fabdf556069d6631545f42aa6e3500f2e", + "timestamp": "2024-08-28T09:15:58Z", + "user_graph_id": "uid:ee11cbb19052e40b07aac0ca060c23ee:0", + "user_name": "root", + }, + "triggering_process_graph_id": "pid:835449907c99453085a924a16e967be5:58913928", + "type": "ldt", + "updated_timestamp": "2024-08-28T11:32:04.582157884Z", + "user_name": "root", + } + + assert verticles_collector.get_graph_ids_from_alert(alert_details) == { + "pid:835449907c99453085a924a16e967be5:58913928", + "pid:835449907c99453085a924a16e967be5:27242182487", + } + + +def test_verticle_collector_get_alert_details_wo_permissions(trigger, verticles_collector): + trigger.use_alert_api = True + + with requests_mock.Mocker() as mock: + mock.register_uri( + "POST", + "https://my.fake.sekoia/oauth2/token", + json={ + "access_token": "foo-token", + "token_type": "bearer", + "expires_in": 1799, + }, + ) + + mock.register_uri( + "POST", + "https://my.fake.sekoia/alerts/entities/alerts/v2", + json={ + "meta": { + "query_time": 0.05286448, + "writes": {"resources_affected": 0}, + "powered_by": "detectsapi", + "trace_id": "2d80bb22-a21b-4adc-ae09-e6e155f87eb9", + }, + "errors": [ + { + "code": 403, + "message": "don't have proper permissions", + } + ], + "resources": [], + }, + status_code=403, + ) + + _ = list(verticles_collector.collect_verticles_from_alert(composite_id="id:123456")) + assert trigger.use_alert_api is False + + def test_verticle_collector_collect_verticles_from_graph_ids(verticles_collector): graph_ids = { "pid:835449907c99453085a924a16e967be5:8322695771", @@ -861,6 +931,306 @@ def test_read_stream_with_verticles(trigger): assert actual_events == expected_events +def test_read_stream_with_verticles_with_alert(trigger): + trigger.use_alert_api = True + detection_id = "ldt:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa:11111111111" + + stream = { + "dataFeedURL": "https://my.fake.sekoia/sensors/entities/datafeed/v1/stream?q=1", + "sessionToken": { + "token": "my_token==", + "expiration": "2022-07-06T12:39:24.017018689Z", + }, + "refreshActiveSessionURL": ( + "https://my.fake.sekoia/sensors/entities/datafeed-actions" + "/v1/0?appId=sio-00000&action_name=refresh_active_stream_session" + ), + "refreshActiveSessionInterval": 1800, + } + + severity_code = 5 + severity_name = "Critical" + event = { + "metadata": { + "customerIDString": "11111111111111111111111111111111", + "offset": 174, + "eventType": "EppDetectionSummaryEvent", + "eventCreationTime": 1657110865303, + "version": "1.0", + }, + "event": { + "ProcessStartTime": 1656688889, + "ProcessEndTime": 0, + "ProcessId": 22164474048, + "ParentProcessId": 22163465296, + "ComputerName": "nsewmkzevukn-vm", + "UserName": "Administrator", + "DetectName": "Overwatch Detection", + "DetectDescription": "Falcon Overwatch has identified malicious activity carried out by a suspected or known eCrime operator. This activity has been raised for critical action and should be investigated urgently.", # noqa: E501 + "Severity": severity_code, + "SeverityName": severity_name, + "FileName": "explorer.exe", + "FilePath": "\\Device\\HarddiskVolume2\\Windows", + "CommandLine": "C:\\Windows\\Explorer.EXE", + "SHA256String": "249cb3cb46fd875196e7ed4a8736271a64ff2d8132357222a283be53e7232ed3", + "MD5String": "d45bd7c7b7bf977246e9409d63435231", + "SHA1String": "0000000000000000000000000000000000000000", + "MachineDomain": "nsewmkzevukn-vm", + "CompositeId": detection_id, + }, + } + + parent_process_graph_id = "pid:835449907c99453085a924a16e967be5:8322695771" + triggering_process_graph_id = "pid:835449907c99453085a924a16e967be5:8302912087" + + alert_details = { + "composite_id": detection_id, + "control_graph_id": "ctg:835449907c99453085a924a16e967be5:17212155109", + "parent_details": { + "cmdline": "/usr/lib/git-core/git fetch --update-head-ok", + "filename": "git", + "filepath": "/usr/lib/git-core/git", + "local_process_id": "1", + "md5": "1bc29b36f623ba82aaf6724fd3b16718", + "process_graph_id": parent_process_graph_id, + "process_id": "1", + "sha256": "5d5b09f6dcb2d53a5fffc60c4ac0d55fabdf556069d6631545f42aa6e3500f2e", + "timestamp": "2024-08-28T09:15:58Z", + "user_graph_id": "uid:ee11cbb19052e40b07aac0ca060c23ee:0", + "user_name": "root", + }, + "triggering_process_graph_id": triggering_process_graph_id, + "type": "ldt", + "updated_timestamp": "2024-08-28T11:32:04.582157884Z", + "user_name": "root", + } + + verticle1 = { + "id": "pid:835449907c99453085a924a16e967be5:6494700150", + "customer_id": "11111111111111111111111111111111", + "scope": "device", + "object_id": "8322695771", + "device_id": "835449907c99453085a924a16e967be5", + "vertex_type": "process", + "timestamp": "2022-07-30T20:22:29Z", + "properties": {}, + } + verticle2 = { + "id": "pid:835449907c99453085a924a16e967be5:6492874271", + "customer_id": "11111111111111111111111111111111", + "scope": "device", + "object_id": "8302912087", + "device_id": "835449907c99453085a924a16e967be5", + "vertex_type": "process", + "timestamp": "2022-07-30T20:21:51Z", + "properties": {}, + } + verticle3 = { + "id": "pid:835449907c99453085a924a16e967be5:6463227462", + "customer_id": "11111111111111111111111111111111", + "scope": "device", + "object_id": "6463227462", + "device_id": "835449907c99453085a924a16e967be5", + "vertex_type": "process", + "timestamp": "2022-07-30T20:24:12Z", + "properties": {}, + } + + edge_type = "child_process" + with requests_mock.Mocker() as mock: + mock.register_uri( + "POST", + "https://my.fake.sekoia/oauth2/token", + json={ + "access_token": "foo-token", + "token_type": "bearer", + "expires_in": 1799, + }, + ) + + mock.register_uri( + "GET", + "https://my.fake.sekoia/threatgraph/queries/edge-types/v1", + json={ + "resources": [ + "child_process", + ] + }, + ) + + mock.register_uri( + "GET", + "https://my.fake.sekoia/sensors/entities/datafeed/v2?appId=sio-00000", + json={ + "errors": [], + "meta": { + "query_time": 0.008346086, + "trace_id": "13787250-fc0f-49a6-9191-d809e30afdfb", + }, + "resources": [stream], + }, + ) + + mock.register_uri( + "GET", + "https://my.fake.sekoia/sensors/entities/datafeed/v1/stream?q=1", + content=orjson.dumps(event) + b"\n", + ) + + mock.register_uri( + "POST", + "https://my.fake.sekoia/alerts/entities/alerts/v2", + json={ + "errors": [], + "meta": { + "query_time": 0.008346086, + "trace_id": "13787250-fc0f-49a6-9191-d809e30afdfb", + }, + "resources": [alert_details], + }, + ) + + mock.register_uri( + "GET", + f"https://my.fake.sekoia/threatgraph/combined/edges/v1?edge_type={edge_type}&ids={parent_process_graph_id}", # noqa: E501 + json={ + "errors": [], + "meta": {}, + "resources": [ + { + "edge_type": edge_type, + "id": "pid:835449907c99453085a924a16e967be5:6494700150", + "source_vertex_id": parent_process_graph_id, + }, + { + "edge_type": edge_type, + "id": "pid:835449907c99453085a924a16e967be5:6492874271", + "source_vertex_id": parent_process_graph_id, + }, + ], + }, + ) + + mock.register_uri( + "GET", + f"https://my.fake.sekoia/threatgraph/combined/edges/v1?edge_type={edge_type}&ids={triggering_process_graph_id}", # noqa: E501 + json={ + "errors": [], + "meta": {}, + "resources": [ + { + "edge_type": edge_type, + "id": "pid:835449907c99453085a924a16e967be5:6463227462", + "source_vertex_id": triggering_process_graph_id, + } + ], + }, + ) + + mock.register_uri( + "GET", + "https://my.fake.sekoia/threatgraph/entities/processes/v1?scope=device&" + f"ids={verticle1['id']}&" + f"ids={verticle2['id']}", + json={ + "errors": [], + "meta": {}, + "resources": [verticle1, verticle2], + }, + ) + + mock.register_uri( + "GET", + f"https://my.fake.sekoia/threatgraph/entities/processes/v1?scope=device&ids={verticle3['id']}", + json={ + "errors": [], + "meta": {}, + "resources": [verticle3], + }, + ) + + mock.register_uri( + "POST", + f"https://my.fake.sekoia/sensors/entities/datafeed-actions/v1/0?appId=sio-00000&action_name=refresh_active_stream_session", + json={}, + ) + + reader = EventStreamReader( + trigger, + stream["dataFeedURL"].split("?")[0], + stream, + "sio-00000", + 0, + trigger.client, + trigger.verticles_collector, + ) + + reader.start() + + time.sleep(1) + reader.stop() + reader.join() + + expected_verticles = [ + { + "metadata": { + "detectionIdString": detection_id, + "eventType": "Vertex", + "edge": { + "sourceVertexId": parent_process_graph_id, + "type": edge_type, + }, + "severity": { + "name": severity_name, + "code": severity_code, + }, + }, + "event": verticle1, + }, + { + "metadata": { + "detectionIdString": detection_id, + "eventType": "Vertex", + "edge": { + "sourceVertexId": parent_process_graph_id, + "type": edge_type, + }, + "severity": { + "name": severity_name, + "code": severity_code, + }, + }, + "event": verticle2, + }, + { + "metadata": { + "detectionIdString": detection_id, + "eventType": "Vertex", + "edge": { + "sourceVertexId": triggering_process_graph_id, + "type": edge_type, + }, + "severity": { + "name": severity_name, + "code": severity_code, + }, + }, + "event": verticle3, + }, + ] + expected_events = {orjson.dumps(msg).decode() for msg in (event, *expected_verticles)} + + assert trigger.events_queue.qsize() == len(expected_events) + actual_events = set() + try: + while (msg := trigger.events_queue.get(timeout=1)) is not None: + actual_events.add(msg[1]) + except queue.Empty: + pass + + assert actual_events == expected_events + + def test_verticles_collector_with_invalid_credential(symphony_storage): module = CrowdStrikeFalconModule() trigger = EventStreamTrigger(module=module, data_path=symphony_storage) diff --git a/CrowdStrikeFalcon/tests/test_helpers.py b/CrowdStrikeFalcon/tests/test_helpers.py index 206bb0471..f00dc42cb 100644 --- a/CrowdStrikeFalcon/tests/test_helpers.py +++ b/CrowdStrikeFalcon/tests/test_helpers.py @@ -2,10 +2,10 @@ from crowdstrike_falcon.helpers import ( VerticleID, + compute_refresh_interval, get_detection_id, get_extended_verticle_type, group_edges_by_verticle_type, - compute_refresh_interval, )