diff --git a/README.md b/README.md index 04db95c11..2a655a673 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,8 @@ Workflow triggers can either be executed manually when an alert is activated or                       + +                       diff --git a/docs/mint.json b/docs/mint.json index 99e9a8b8a..4ea64a81a 100644 --- a/docs/mint.json +++ b/docs/mint.json @@ -159,6 +159,7 @@ "providers/documentation/squadcast-provider", "providers/documentation/ssh-provider", "providers/documentation/statuscake-provider", + "providers/documentation/sumologic-provider", "providers/documentation/teams-provider", "providers/documentation/telegram-provider", "providers/documentation/template", diff --git a/docs/providers/documentation/sumologic-provider.mdx b/docs/providers/documentation/sumologic-provider.mdx new file mode 100644 index 000000000..6e1be21b2 --- /dev/null +++ b/docs/providers/documentation/sumologic-provider.mdx @@ -0,0 +1,36 @@ +--- +title: "SumoLogic Provider" +sidebarTitle: "SumoLogic Provider" +description: "The SumoLogic provider enables webhook installations for receiving alerts in keep" +--- + +## Overview + +The SumoLogic provider facilitates receiving alerts from Monitors in SumoLogic using a Webhook Connection. + +## Authentication Parameters + +- `sumoLogicAccessId`: API key for authenticating with SumoLogic's API. +- `sumoLogicAccessKey`: API key for authenticating with SumoLogic's API. +- `deployment`: API key for authenticating with SumoLogic's API. + +## Scopes + +- `authenticated`: Mandatory for all operations, ensures the user is authenticated. +- `authorized`: Mandatory for querying incidents, ensures the user has read access. + +## Connecting with the Provider + +1. Follow the instructions [here](https://help.sumologic.com/docs/manage/security/access-keys/) to get your Access Key & Access ID +2. Make sure the user has roles with the following capabilities: + - `manageScheduledViews` + - `manageConnections` + - `manageUsersAndRoles` +3. Find your `deployment` from [here](https://api.sumologic.com/docs/#section/Getting-Started/API-Endpoints), keep will automatically figure out your endpoint. + +## Useful Links + +- [SumoLogic API Documentation](https://api.sumologic.com/docs/#section/Getting-Started) +- [SumoLogic Access_Keys](https://help.sumologic.com/docs/manage/security/access-keys/) +- [SumoLogic Roles Management](https://help.sumologic.com/docs/manage/users-roles/roles/create-manage-roles/) +- [SumoLogic Deployments](https://api.sumologic.com/docs/#section/Getting-Started/API-Endpoints) diff --git a/keep-ui/public/icons/sumologic-icon.png b/keep-ui/public/icons/sumologic-icon.png new file mode 100644 index 000000000..4a6e02ae2 Binary files /dev/null and b/keep-ui/public/icons/sumologic-icon.png differ diff --git a/keep/providers/sumologic_provider/__init__.py b/keep/providers/sumologic_provider/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/keep/providers/sumologic_provider/connection_template.json b/keep/providers/sumologic_provider/connection_template.json new file mode 100644 index 000000000..639d40c08 --- /dev/null +++ b/keep/providers/sumologic_provider/connection_template.json @@ -0,0 +1,20 @@ +{ + "name": "{{Name}}", + "description": "{{Description}}", + "monitorType": "{{MonitorType}}", + "query": "{{Query}}", + "queryURL": "{{QueryURL}}", + "resultsJson": "{{ResultsJson}}", + "numQueryResults": "{{NumQueryResults}}", + "id": "{{Id}}", + "detectionMethod": "{{DetectionMethod}}", + "triggerType": "{{TriggerType}}", + "triggerTimeRange": "{{TriggerTimeRange}}", + "triggerTime": "{{TriggerTime}}", + "triggerCondition": "{{TriggerCondition}}", + "triggerValue": "{{TriggerValue}}", + "triggerTimeStart": "{{TriggerTimeStart}}", + "triggerTimeEnd": "{{TriggerTimeEnd}}", + "sourceURL": "{{SourceURL}}", + "alertResponseUrl": "{{AlertResponseUrl}}" +} diff --git a/keep/providers/sumologic_provider/sumologic_provider.py b/keep/providers/sumologic_provider/sumologic_provider.py new file mode 100644 index 000000000..913cb131b --- /dev/null +++ b/keep/providers/sumologic_provider/sumologic_provider.py @@ -0,0 +1,480 @@ +""" +SumoLogic Provider is a class that allows to install webhooks in SumoLogic. +""" + +import dataclasses +import json +import tempfile +from datetime import datetime +from pathlib import Path +from typing import List, Optional +from urllib.parse import urlencode, urljoin, urlparse + +import pydantic +import requests + +from keep.api.models.alert import AlertDto, AlertSeverity, AlertStatus +from keep.contextmanager.contextmanager import ContextManager +from keep.providers.base.base_provider import BaseProvider +from keep.providers.models.provider_config import ProviderConfig, ProviderScope + + +class ResourceAlreadyExists(Exception): + def __init__(self, *args): + super().__init__(*args) + + +@pydantic.dataclasses.dataclass +class SumologicProviderAuthConfig: + """ + SumoLogic authentication configuration. + """ + + sumoAccessId: str = dataclasses.field( + metadata={ + "required": True, + "description": "SumoLogic Access ID", + "hint": "Your AccessID", + }, + ) + sumoAccessKey: str = dataclasses.field( + metadata={ + "required": True, + "description": "SumoLogic Access Key", + "hint": "SumoLogic Access Key ", + "sensitive": True, + }, + ) + + deployment: str = dataclasses.field( + metadata={ + "required": True, + "description": "Deployment Region", + "hint": "Your deployment Region: AU | CA | DE | EU | FED | IN | JP | KR | US1 | US2", + }, + ) + + +class SumologicProvider(BaseProvider): + """Install Webhooks and receive alerts from SumoLogic.""" + + PROVIDER_DISPLAY_NAME = "SumoLogic" + + PROVIDER_SCOPES = [ + ProviderScope( + name="authenticated", + description="User is Authorized", + mandatory=True, + mandatory_for_webhook=True, + alias="Rules Reader", + ), + ProviderScope( + name="authorized", + description="Required privileges", + mandatory=True, + mandatory_for_webhook=True, + alias="Rules Reader", + ), + ] + + SEVERITIES_MAP = { + "CRITICAL": AlertSeverity.CRITICAL, + "WARNING": AlertSeverity.WARNING, + "INFO": AlertSeverity.INFO, + } + STATUS_MAP = {"firing": AlertStatus.FIRING, "resolved": AlertStatus.RESOLVED} + + def __init__( + self, context_manager: ContextManager, provider_id: str, config: ProviderConfig + ): + super().__init__(context_manager, provider_id, config) + + def dispose(self): + """ + Dispose the provider. + """ + pass + + def validate_config(self): + """ + Validates required configuration for SumoLogic provider. + + """ + self.authentication_config = SumologicProviderAuthConfig( + **self.config.authentication + ) + + def __get_headers(self): + return { + "Content-Type": "application/json", + "Accept": "application/json", + } + + def __get_url(self, paths: List[str] = [], query_params: dict = None, **kwargs): + """ + Helper method to build the url for SumoLogic api requests. + + Example: + + paths = ["issue", "createmeta"] + query_params = {"projectKeys": "key1"} + url = __get_url("test", paths, query_params) + # url = https://baseballxyz.saas.appdynamics.com/rest/api/2/issue/createmeta?projectKeys=key1 + """ + if self.authentication_config.deployment.lower() != "us1": + host = f"https://api.{self.authentication_config.deployment.lower()}.sumologic.com/api/v1/" + else: + host = "https://api.sumologic.com/api/v1/" + url = urljoin( + host, + "/".join(str(path) for path in paths), + ) + + # add query params + if query_params: + url = f"{url}?{urlencode(query_params)}" + + return url + + def validate_scopes(self) -> dict[str, bool | str]: + perms = {"manageScheduledViews", "manageConnections", "manageUsersAndRoles"} + self.logger.info("Validating SumoLogic authentication.") + try: + x = self.__get_url(paths=["account", "accountOwner"]) + account_owner_response = requests.get( + url=self.__get_url(paths=["account", "accountOwner"]), + auth=self.__get_auth(), + headers=self.__get_headers(), + ) + + if account_owner_response.status_code == 200: + authenticated = True + user_id = account_owner_response.json() + self.logger.info( + "Successfully retrieved user_id", extra={"user_id": user_id} + ) + else: + account_owner_response = account_owner_response.json() + self.logger.error( + f"Error while getting UserID", + extra={"error": str(account_owner_response)}, + ) + return { + "authenticated": str(account_owner_response), + "authorized": "Unauthorized", + } + + self.logger.info("Fetching account info...", extra={"user_id": user_id}) + account_info_response = requests.get( + url=self.__get_url(paths=["users", user_id]), + auth=self.__get_auth(), + headers=self.__get_headers(), + ) + + if account_info_response.status_code == 200: + role_ids = account_info_response.json()["roleIds"] + self.logger.info( + "Successfully fetched account info", extra={"roles": role_ids} + ) + else: + account_info_response = account_info_response.json() + self.logger.error( + "Error while getting account info", + extra={"error": str(account_info_response)}, + ) + return { + "authenticated": authenticated, + "authorized": str(account_info_response), + } + + # Checking if the required permissions exists + for role_id in role_ids: + role_info_response = requests.get( + url=self.__get_url(paths=["roles", role_id]), + auth=self.__get_auth(), + headers=self.__get_headers(), + ) + if role_info_response.status_code == 200: + role_info_response = role_info_response.json() + self.logger.info(f"Successfully fetched role: {role_id}") + for capability in role_info_response["capabilities"]: + if capability in perms: + perms.remove(capability) + else: + role_info_response = role_info_response.json() + self.logger.error( + f"Error while getting role: {role_id}", + extra={"error": str(role_info_response)}, + ) + return { + "authenticated": True, + "authorized": str(role_info_response), + } + if len(perms) == 0: + self.logger.info("All required perms found, user is authorized :)") + return {"authenticated": True, "authorized": True} + + except Exception as e: + self.logger.error("Error while getting User ID " + str(e)) + return {"authenticated": str(e), "authorized": str(e)} + + def __get_auth(self) -> tuple[str, str]: + return ( + self.authentication_config.sumoAccessId, + self.authentication_config.sumoAccessKey, + ) + + def __get_connection_id(self, connection_name: str): + params = {"limit": 1000} + while True: + connections_response = requests.get( + url=self.__get_url(paths=["connections"]), + headers=self.__get_headers(), + params=params, + auth=self.__get_auth(), + ) + if connections_response.status_code != 200: + raise Exception(str(connections_response.json())) + connections_response = connections_response.json() + for connection in connections_response["data"]: + if connection["name"] == connection_name: + return connection["id"] + + if connections_response["next"] is None: + break + params["token"] = connections_response["next"] + return None + + def __update_existing_connection(self, connection_id: str, connection_payload): + self.logger.info(f"Updating the connection: {connection_id}") + connection_update_response = requests.put( + url=self.__get_url(paths=["connections", connection_id]), + headers=self.__get_headers(), + auth=self.__get_auth(), + json=connection_payload, + ) + if connection_update_response.status_code == 200: + self.logger.info(f"Successfully updated connection: {connection_id}") + return connection_update_response.json()["id"] + else: + connection_update_response = connection_update_response.json() + self.logger.error( + f"Error while updating connection: {connection_id}", + extra={"error": str(connection_update_response)}, + ) + raise Exception(str(connection_update_response)) + + def __create_connection(self, connection_payload, connection_name: str): + self.logger.info("Creating a Webhook connection with Sumo Logic") + + try: + connection_creation_response = requests.post( + url=self.__get_url(paths=["connections"]), + json=connection_payload, + headers=self.__get_headers(), + auth=self.__get_auth(), + ) + if connection_creation_response.status_code == 200: + self.logger.info("Successfully created Webhook connection") + return connection_creation_response.json()["id"] + if connection_creation_response.status_code == 400: + connection_creation_response = connection_creation_response.json() + if ( + connection_creation_response["errors"][0]["code"] + == "connection:name_already_exists" + ): + self.logger.info( + "Webhook connection already exists, attempting to update it" + ) + connection_id = self.__get_connection_id( + connection_name=connection_name + ) + return self.__update_existing_connection( + connection_payload=connection_payload, + connection_id=connection_id, + ) + + raise Exception(str(connection_creation_response)) + else: + connection_creation_response = connection_creation_response.json() + self.logger.error( + "Error while creating webhook connection", + extra={"error": str(connection_creation_response)}, + ) + raise Exception(connection_creation_response) + except Exception as e: + self.logger.error("Error while creating webhook connection " + str(e)) + raise e + + def __get_monitors_without_keep(self, connection_id: str): + monitors = [] + params = {"query": "type:monitor"} + monitors_response = requests.get( + url=self.__get_url(paths=["monitors", "search"]), + params=params, + headers=self.__get_headers(), + auth=self.__get_auth(), + ) + + if monitors_response.status_code == 200: + self.logger.info("Successfully fetched all monitors") + monitors_response = monitors_response.json() + for monitor in monitors_response: + print(monitor) + for notification in monitor["item"]["notifications"]: + if notification["notification"]["connectionId"] == connection_id: + break + else: + monitors.append(monitor["item"]) + return monitors + else: + monitors_response = monitors_response.json() + self.logger.error( + "Error while getting monitors", extra=str(monitors_response) + ) + raise Exception(str(monitors_response)) + + def __install_connection_in_monitor(self, monitor, connection_id: str): + self.logger.info(f"Installing connection to monitor: {monitor['name']}") + monitor["type"] = "MonitorsLibraryMonitorUpdate" + triggers = [trigger["triggerType"] for trigger in monitor["triggers"]] + keep_notification = { + "notification": { + "connectionType": "Webhook", + "connectionId": connection_id, + "payloadOverride": None, + "resolutionPayloadOverride": None, + }, + "runForTriggerTypes": triggers, + } + monitor["notifications"].append(keep_notification) + monitor_update_response = requests.put( + url=self.__get_url(paths=["monitors", monitor["id"]]), + headers=self.__get_headers(), + auth=self.__get_auth(), + json=monitor, + ) + if monitor_update_response.status_code == 200: + self.logger.info( + f"Successfully installed connection to monitor: {monitor['name']}" + ) + else: + raise Exception(str(monitor_update_response.json())) + + def setup_webhook( + self, tenant_id: str, keep_api_url: str, api_key: str, setup_alerts: bool = True + ): + try: + parsed_url = urlparse(keep_api_url) + + # Extract the query string + query_params = parsed_url.query + + # Find the provider_id in the query parameters + # connection_template.json is the payload that will be sent to keep as an event + provider_id = query_params.split("provider_id=")[-1] + connection_name = f"KeepHQ-{provider_id}" + connection_payload = { + "type": "WebhookDefinition", + "name": connection_name, + "description": "A webhook connection that pushes alerts to KeepHQ", + "url": keep_api_url, + "headers": [], + "customHeaders": [{"name": "X-API-KEY", "value": api_key}], + "defaultPayload": open( + rf"{Path(__file__).parent}/connection_template.json" + ).read(), + "webhookType": "Webhook", + "connectionSubtype": "Event", + "resolutionPayload": open( + rf"{Path(__file__).parent}/connection_template.json" + ).read(), + } + # Creating a sumo logic connection + connection_id = self.__create_connection( + connection_payload=connection_payload, connection_name=connection_name + ) + + # Monitors + monitors = self.__get_monitors_without_keep(connection_id=connection_id) + + # Install connections in monitors that don't have keep + for monitor in monitors: + self.__install_connection_in_monitor( + monitor=monitor, connection_id=connection_id + ) + except Exception as e: + raise e + + @staticmethod + def __extract_severity(severity: str): + if "critical" in severity.lower(): + return SumologicProvider.SEVERITIES_MAP.get("CRITICAL") + elif "warning" in severity.lower(): + return SumologicProvider.SEVERITIES_MAP.get("WARNING") + elif "missing" in severity.lower(): + return SumologicProvider.SEVERITIES_MAP.get("INFO") + + @staticmethod + def __extract_status(status: str): + if "resolved" in status.lower(): + return SumologicProvider.STATUS_MAP.get("resolved") + else: + return SumologicProvider.STATUS_MAP.get("firing") + + @staticmethod + def _format_alert( + event: dict, + provider_instance: Optional["SumologicProvider"] = None, + ) -> AlertDto: + return AlertDto( + id=event["id"], + name=event["name"], + severity=SumologicProvider.__extract_severity( + severity=event["triggerType"] + ), + fingerprint=event["id"], + status=SumologicProvider.__extract_status(status=event["triggerType"]), + lastReceived=datetime.utcfromtimestamp( + int(event["triggerTimeStart"]) / 1000 + ).isoformat() + + "Z", + firingTimeStart=datetime.utcfromtimestamp( + int(event["triggerTimeStart"]) / 1000 + ).isoformat() + + "Z", + description=event["description"], + url=event["alertResponseUrl"], + source=["sumologic"], + ) + + +if __name__ == "__main__": + import logging + + logging.basicConfig(level=logging.DEBUG, handlers=[logging.StreamHandler()]) + context_manager = ContextManager( + tenant_id="singletenant", + workflow_id="test", + ) + + import os + + # api_key = os.getenv("INCIDENTIO_API_KEY") + + config = ProviderConfig( + description="Sumo Logic Provider", + authentication={ + "sumoAccessId": "suOOgc0GyKCgQK", + "sumoAccessKey": "Wiu8EzqHJzZHwowrTBo77vfSs4Xyxboxe3odhl0ZUSHrcn2vMgtQVL9j66h6koyK", + "deployment": "US1", + }, + ) + + provider = SumologicProvider( + context_manager, + provider_id="incidentio_provider", + config=config, + ) + print(provider.setup_webhook("a", "https://tasks.copyrightable.com/", "c", True)) + # print(provider._get_alerts())