diff --git a/Sophos/CHANGELOG.md b/Sophos/CHANGELOG.md index 25994a8f5..6a8278360 100644 --- a/Sophos/CHANGELOG.md +++ b/Sophos/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## 2024-10-02 - 1.17.0 + +### Added + +- Added Sophos EDR endpoint actions + ## 2024-08-06 - 1.16.5 ### Fixed diff --git a/Sophos/action_sophos_edr_deisolate.json b/Sophos/action_sophos_edr_deisolate.json new file mode 100644 index 000000000..2c3692a2d --- /dev/null +++ b/Sophos/action_sophos_edr_deisolate.json @@ -0,0 +1,19 @@ +{ + "arguments": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "endpoint_id": { + "description": "Endpoint ID", + "type": "string" + } + }, + "required": ["endpoint_id"], + "title": "Arguments", + "type": "object" + }, + "description": "Turn off endpoint isolation", + "docker_parameters": "sophos_edr_deisolate_endpoint", + "name": "[BETA] Deisolate endpoint", + "results": {}, + "uuid": "258030ab-8275-4056-877e-105cb74de49d" +} diff --git a/Sophos/action_sophos_edr_isolate.json b/Sophos/action_sophos_edr_isolate.json new file mode 100644 index 000000000..32804bd00 --- /dev/null +++ b/Sophos/action_sophos_edr_isolate.json @@ -0,0 +1,19 @@ +{ + "arguments": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "endpoint_id": { + "description": "Endpoint ID", + "type": "string" + } + }, + "required": ["endpoint_id"], + "title": "Arguments", + "type": "object" + }, + "description": "Turn on endpoint isolation", + "docker_parameters": "sophos_edr_isolate_endpoint", + "name": "[BETA] Isolate endpoint", + "results": {}, + "uuid": "91d24356-6537-4a3e-ba6f-19c7963db8fe" +} diff --git a/Sophos/action_sophos_edr_run_scan.json b/Sophos/action_sophos_edr_run_scan.json new file mode 100644 index 000000000..1eb4a89b9 --- /dev/null +++ b/Sophos/action_sophos_edr_run_scan.json @@ -0,0 +1,19 @@ +{ + "arguments": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "endpoint_id": { + "description": "Endpoint ID", + "type": "string" + } + }, + "required": ["endpoint_id"], + "title": "Arguments", + "type": "object" + }, + "description": "", + "docker_parameters": "sophos_edr_run_scan", + "name": "[BETA] Run scan", + "results": {}, + "uuid": "bc98176f-10d5-46e8-9a43-8694e8ea4e68" +} diff --git a/Sophos/main.py b/Sophos/main.py index 8a0c577a4..6a189b77f 100644 --- a/Sophos/main.py +++ b/Sophos/main.py @@ -1,3 +1,6 @@ +from sophos_module.action_sophos_edr_deisolate import ActionSophosEDRDeIsolateEndpoint +from sophos_module.action_sophos_edr_isolate import ActionSophosEDRIsolateEndpoint +from sophos_module.action_sophos_edr_run_scan import ActionSophosEDRScan from sophos_module.base import SophosModule from sophos_module.trigger_sophos_edr_events import SophosEDREventsTrigger from sophos_module.trigger_sophos_xdr_query import SophosXDRIOCQuery @@ -6,4 +9,8 @@ module = SophosModule() module.register(SophosEDREventsTrigger, "sophos_events_trigger") module.register(SophosXDRIOCQuery, "sophos_query_ioc_trigger") + module.register(ActionSophosEDRIsolateEndpoint, "sophos_edr_isolate_endpoint") + module.register(ActionSophosEDRDeIsolateEndpoint, "sophos_edr_deisolate_endpoint") + module.register(ActionSophosEDRScan, "sophos_edr_run_scan") + module.run() diff --git a/Sophos/manifest.json b/Sophos/manifest.json index 238496df1..bf11eebf0 100644 --- a/Sophos/manifest.json +++ b/Sophos/manifest.json @@ -38,7 +38,7 @@ "name": "Sophos", "uuid": "0de5216e-19b0-4ad3-9b91-a547cfaf52ca", "slug": "sophos", - "version": "1.16.5", + "version": "1.17.0", "categories": [ "Endpoint" ] diff --git a/Sophos/sophos_module/action_base.py b/Sophos/sophos_module/action_base.py new file mode 100644 index 000000000..2e440d747 --- /dev/null +++ b/Sophos/sophos_module/action_base.py @@ -0,0 +1,73 @@ +from abc import ABC +from functools import cached_property +from typing import Any, Callable +from urllib.parse import urljoin + +from sekoia_automation.action import Action + +from .base import SophosModule +from .client import SophosApiClient +from .client.auth import SophosApiAuthentication +from .logging import get_logger + +logger = get_logger() + + +class SophosEDRAction(Action, ABC): + module: SophosModule + + @cached_property + def client(self) -> SophosApiClient: + auth = SophosApiAuthentication( + api_host=self.module.configuration.api_host, + authorization_url=self.module.configuration.oauth2_authorization_url, + client_id=self.module.configuration.client_id, + client_secret=self.module.configuration.client_secret, + ) + return SophosApiClient(auth=auth) + + @cached_property + def region_base_url(self) -> str: + url = urljoin(self.module.configuration.api_host, "whoami/v1") + response = self.client.get(url).json() + + return str(response["apiHosts"]["dataRegion"]) + + def call_endpoint( + self, method: str, url: str, data: dict[str, Any] | None = None, use_region_url: bool = False + ) -> Any: + assert method.lower() in ("get", "post", "patch") + + base_url = self.region_base_url if use_region_url else self.module.configuration.api_host + url = urljoin(base_url, url) + + func: Callable[[Any], Any] + if method.lower() == "post": + func = self.client.post + + elif method.lower() == "patch": + func = self.client.patch + + else: + func = self.client.get + + response = func(url=url, json=data) + + if response.ok: + return response.json() + + else: + raw = response.json() + logger.error( + f"Error {response.status_code}", + error=raw.get("error"), + message=raw.get("message"), + corellation_id=raw.get("correlationId"), + code=raw.get("code"), + created_at=raw.get("createdAt"), + request_id=raw.get("requestId"), + doc_url=raw.get("docUrl"), + ) + response.raise_for_status() + + return {} diff --git a/Sophos/sophos_module/action_sophos_edr_deisolate.py b/Sophos/sophos_module/action_sophos_edr_deisolate.py new file mode 100644 index 000000000..539db4823 --- /dev/null +++ b/Sophos/sophos_module/action_sophos_edr_deisolate.py @@ -0,0 +1,11 @@ +from typing import Any + +from sophos_module.action_sophos_edr_isolate import ActionSophosEDRIsolateEndpoint + + +class ActionSophosEDRDeIsolateEndpoint(ActionSophosEDRIsolateEndpoint): + def run(self, arguments: dict[str, Any]) -> Any: + endpoint_id = arguments["endpoint_id"] + comment = arguments.get("comment") + + return self.set_isolation_status(endpoint_id=endpoint_id, enabled=False, comment=comment) diff --git a/Sophos/sophos_module/action_sophos_edr_isolate.py b/Sophos/sophos_module/action_sophos_edr_isolate.py new file mode 100644 index 000000000..bb2ec8a16 --- /dev/null +++ b/Sophos/sophos_module/action_sophos_edr_isolate.py @@ -0,0 +1,61 @@ +from typing import Any + +import requests + +from sophos_module.action_base import SophosEDRAction + + +class ActionSophosEDRIsolateEndpoint(SophosEDRAction): + def get_endpoint_isolation_status(self, endpoint_id: str) -> Any: + return self.call_endpoint( + method="get", url=f"endpoint/v1/endpoints/{endpoint_id}/isolation", use_region_url=True + ) + + def try_to_set_isolation_state(self, endpoint_id: str, enabled: bool, comment: str | None = None) -> Any: + data: dict[str, Any] = {"enabled": enabled} + if comment is not None: + data["comment"] = comment + + return self.call_endpoint( + method="patch", url=f"endpoint/v1/endpoints/{endpoint_id}/isolation", data=data, use_region_url=True + ) + + def set_isolation_status(self, endpoint_id: str, enabled: bool, comment: str | None = None) -> Any: + """ + Sophos Endpoint API will return `Bad Request` every time you will + try to enable/disable already enabled/disabled isolation on an endpoint, + or whenever there's too little time passed between status changes. So we + have to make double checks and enrich returned messages + """ + # check whether endpoint is already in the desired state + current_status = self.get_endpoint_isolation_status(endpoint_id) + if current_status["enabled"] == enabled: + result_label = "enabled" if enabled else "disabled" + current_status["message"] = "Already %s" % result_label + return current_status + + try: + response = self.try_to_set_isolation_state(endpoint_id=endpoint_id, enabled=enabled, comment=comment) + return response + + except requests.exceptions.HTTPError as err: + if err.response.status_code == 400: + # returned as "bad request" in two cases: + # 1. endpoint is already in the desired state - we checked for that before + # 2. too little time passed since an isolation state was changed - need to wait + result = err.response.json() + result.update( + { + "error": "Isolation status was changed recently", + "message": "We recommend that you wait for a period of time " + "between turning endpoint isolation on and off", + } + ) + result.update(current_status) + return result + + def run(self, arguments: dict[str, Any]) -> Any: + endpoint_id = arguments["endpoint_id"] + comment = arguments.get("comment") + + return self.set_isolation_status(endpoint_id=endpoint_id, enabled=True, comment=comment) diff --git a/Sophos/sophos_module/action_sophos_edr_run_scan.py b/Sophos/sophos_module/action_sophos_edr_run_scan.py new file mode 100644 index 000000000..ea10d5ed6 --- /dev/null +++ b/Sophos/sophos_module/action_sophos_edr_run_scan.py @@ -0,0 +1,12 @@ +from typing import Any + +from sophos_module.action_base import SophosEDRAction + + +class ActionSophosEDRScan(SophosEDRAction): + def run(self, arguments: dict[str, Any]) -> Any: + endpoint_id = arguments["endpoint_id"] + + return self.call_endpoint( + method="post", url=f"endpoint/v1/endpoints/{endpoint_id}/scans", data={}, use_region_url=True + ) diff --git a/Sophos/tests/test_sophos_edr_actions.py b/Sophos/tests/test_sophos_edr_actions.py new file mode 100644 index 000000000..f2fd8e578 --- /dev/null +++ b/Sophos/tests/test_sophos_edr_actions.py @@ -0,0 +1,146 @@ +import pytest +import requests.exceptions +import requests_mock + +from sophos_module.action_sophos_edr_deisolate import ActionSophosEDRDeIsolateEndpoint +from sophos_module.action_sophos_edr_isolate import ActionSophosEDRIsolateEndpoint +from sophos_module.action_sophos_edr_run_scan import ActionSophosEDRScan +from sophos_module.base import SophosModule + + +@pytest.fixture +def module(symphony_storage): + module = SophosModule() + module.configuration = { + "oauth2_authorization_url": "https://id.sophos.com/api/v2/oauth2/token", + "api_host": "https://api-eu.central.sophos.com", + "client_id": "my-id", + "client_secret": "my-password", + } + return module + + +def add_auth_mock(mock, host, module): + mock.post( + f"{module.configuration.oauth2_authorization_url}", + status_code=200, + json={ + "access_token": "access_token", + "refresh_token": "refresh_token", + "token_type": "bearer", + "message": "OK", + "errorCode": "success", + "expires_in": 3600, + }, + ) + + mock.get( + f"{module.configuration.api_host}/whoami/v1", + status_code=200, + json={ + "id": "ea106f70-96b1-4851-bd31-e4395ea407d2", + "idType": "tenant", + "apiHosts": { + "global": "https://api.central.sophos.com", + "dataRegion": host, + }, + }, + ) + + +def test_run_scan(module) -> None: + with requests_mock.Mocker() as mock: + host = "https://api-eu01.central.sophos.com" + add_auth_mock(mock, host, module) + + url = f"{host}/endpoint/v1/endpoints/0b44b37f-2299-47c8-bf5d-589995f8de96/scans" + mock.post( + url, + json={ + "id": "41d45256-d5e0-4f56-bfb4-57c5de6909d9", + "status": "requested", + "requestedAt": "2024-10-03T10:29:58.170278201Z", + }, + ) + scan_action = ActionSophosEDRScan(module) + scan_action.run({"endpoint_id": "0b44b37f-2299-47c8-bf5d-589995f8de96"}) + + +def test_isolate_endpoint(module): + message = { + "enabled": True, + "lastEnabledAt": "2024-10-03 09.50.54 UTC", + "lastEnabledBy": {"id": "a1fac7fe-1cf3-46c8-9b8c-9976f518f726"}, + "lastDisabledBy": {"id": "a1fac7fe-1cf3-46c8-9b8c-9976f518f726"}, + } + + with requests_mock.Mocker() as mock: + host = "https://api-eu01.central.sophos.com" + add_auth_mock(mock, host, module) + + url = f"{host}/endpoint/v1/endpoints/0b44b37f-2299-47c8-bf5d-589995f8de96/isolation" + mock.get( + url, + json=message, + ) + mock.patch( + url, + json=message, + ) + isolate_action = ActionSophosEDRIsolateEndpoint(module) + isolate_action.run({"endpoint_id": "0b44b37f-2299-47c8-bf5d-589995f8de96"}) + + +def test_deisolate_endpoint(module): + message = { + "enabled": False, + "lastEnabledAt": "2024-10-03 09.50.54 UTC", + "lastEnabledBy": {"id": "a1fac7fe-1cf3-46c8-9b8c-9976f518f726"}, + "lastDisabledBy": {"id": "a1fac7fe-1cf3-46c8-9b8c-9976f518f726"}, + } + + with requests_mock.Mocker() as mock: + host = "https://api-eu01.central.sophos.com" + add_auth_mock(mock, host, module) + + url = f"{host}/endpoint/v1/endpoints/0b44b37f-2299-47c8-bf5d-589995f8de96/isolation" + mock.get( + url, + json=message, + ) + mock.patch( + url, + json=message, + ) + + deisolate_action = ActionSophosEDRDeIsolateEndpoint(module) + deisolate_action.run({"endpoint_id": "0b44b37f-2299-47c8-bf5d-589995f8de96"}) + + +def test_error(module): + message_1 = { + "enabled": True, + "lastEnabledAt": "2024-10-03 09.50.54 UTC", + "lastEnabledBy": {"id": "a1fac7fe-1cf3-46c8-9b8c-9976f518f726"}, + "lastDisabledBy": {"id": "a1fac7fe-1cf3-46c8-9b8c-9976f518f726"}, + } + message_2 = { + "error": "badRequest", + "correlationId": "c5bd922b-febb-45e2-a9f1-02fb2a8f88d9", + "requestId": "16e35810-0be1-430b-9978-d1e8f959a422", + "createdAt": "2024-10-03T13:52:36.194125443Z", + "message": "Invalid request", + } + with requests_mock.Mocker() as mock: + host = "https://api-eu01.central.sophos.com" + add_auth_mock(mock, host, module) + + url = f"{host}/endpoint/v1/endpoints/0b44b37f-2299-47c8-bf5d-589995f8de96/isolation" + mock.get( + url, + json=message_1, + ) + mock.patch(url, json=message_2, status_code=400) + + deisolate_action = ActionSophosEDRDeIsolateEndpoint(module) + deisolate_action.run({"endpoint_id": "0b44b37f-2299-47c8-bf5d-589995f8de96"})