import functools import traceback import demistomock as demisto # noqa: F401 from CommonServerPython import * # noqa: F401 """ IMPORTS """ # Std imports from datetime import datetime, timezone from base64 import b64decode # 3-rd party imports from typing import Any from collections.abc import Iterator, Sequence import urllib.parse import urllib3 from akamai.edgegrid import EdgeGridAuth # Local imports from CommonServerUserPython import * import asyncio import aiohttp """GLOBALS/PARAMS Attributes: INTEGRATION_NAME: Name of the integration as shown in the integration UI, for example: Microsoft Graph User. INTEGRATION_COMMAND_NAME: Command names should be written in all lower-case letters, and each word separated with a hyphen, for example: msgraph-user. INTEGRATION_CONTEXT_NAME: Context output names should be written in camel case, for example: MSGraphUser. """ INTEGRATION_NAME = "Akamai SIEM" INTEGRATION_COMMAND_NAME = "akamai-siem" INTEGRATION_CONTEXT_NAME = "Akamai" VENDOR = "Akamai" PRODUCT = "WAF" DEFAULT_PAGE_SIZE = 20000 # Default events per request TIME_TO_RUN_BUFFER = 30 # When calculating time left to run, will use this as a safe zone delta. EXECUTION_START_TIME = datetime.now() ALLOWED_PAGE_SIZE_DELTA_RATIO = 0.95 # uses this delta to overcome differences from Akamai When calculating latest request size. MAX_ALLOWED_FETCH_LIMIT = 80000 MAX_INCIDENTS_FETCH_LIMIT = 2000 # Max total incidents per fetch (XSOAR) DEFAULT_INCIDENTS_FETCH_LIMIT = 20 # Default total incidents per fetch DEFAULT_EVENTS_FETCH_LIMIT = 60000 # Default total events per fetch SEND_EVENTS_TO_XSIAM_CHUNK_SIZE = 9 * (10**6) # 9 MB AKAMAI_MAX_LOOKBACK_MINUTES = 715 # 11h55m: max recovery window (12h) minus a 5-minute safety buffer. # Disable insecure warnings urllib3.disable_warnings() class Client(BaseClient): def get_events( self, config_ids: str, offset: str | None = "", limit: str | int | None = None, from_epoch: str | None = "", to_epoch: str | None = "", ) -> tuple[list[Any], Any]: """ Get security events from Akamai WAF service by - https://developer.akamai.com/api/cloud_security/siem/v1.html, Pay attention response as text of multiple json objects Allowed query parameters combinations: 1. offset - Since a prior request. 2. offset, limit - Since a prior request, limited. 3. from - Since a point in time. 4. from, limit - Since a point in time, limited. 5. from, to - Over a range of time. 6. from, to, limit - Over a range of time, limited. Args: config_ids: Unique identifier for each security configuration. To report on more than one configuration, separate integer identifiers with semicolons, e.g. 12892;29182;82912. offset: This token denotes the last message. If specified, this operation fetches only security events that have occurred from offset. This is a required parameter for offset mode and you can't use it in time-based requests. limit: Defines the approximate maximum number of security events each fetch returns, in both offset and time-based modes. The default limit is 20000. Expect requests to return a slightly higher number of security events than you set in the limit parameter, because data is stored in different buckets. from_epoch: The start of a specified time range, expressed in Unix epoch seconds. This is a required parameter to get time-based results for a set period, and you can't use it in offset mode. to_epoch: The end of a specified time range, expressed in Unix epoch seconds. You can't use this parameter in offset mode and it's an optional parameter in time-based mode. If omitted, the value defaults to the current time. Returns: Multiple json objects as list of dictionaries, offset for next pagination """ params = { "offset": offset, "limit": limit, "to": to_epoch, "from": from_epoch, } raw_response: str = self._http_request( method="GET", url_suffix=f"/{config_ids}", params=assign_params(**params), resp_type="text" ) events: list = [] if '{ "total": 0' not in raw_response: events = [json.loads(event) for event in raw_response.split("\n")[:-2]] new_offset = str(max([int(event.get("httpMessage", {}).get("start")) for event in events])) else: new_offset = str(from_epoch) return events, new_offset def execute_get_events_request(self, params: dict[str, int | str], config_ids: str, prefix_msg: str = ""): demisto.debug(f"[Get Events] {prefix_msg}Init session and sending request to Akamai.") url_suffix = f"/{config_ids}" if "offset" in params: url_suffix = f"{url_suffix}?offset={params['offset']}" del params["offset"] raw_response: str = self._http_request( method="GET", url_suffix=url_suffix, params=params, resp_type="text", ) demisto.debug(f"[Get Events] {prefix_msg}Finished executing request to Akamai, processing response.") return raw_response def prepare_params(self, limit, offset, from_epoch, prefix_msg: str = "") -> dict[str, int | str]: params: dict[str, int | str] = {"limit": limit} if offset: demisto.debug(f"[Get Events] {prefix_msg}Received {offset=}, running an offset based request.") params["offset"] = offset else: from_param = int(from_epoch) params["from"] = from_param demisto.debug(f"[Get Events] {prefix_msg} No offset received, running a time based request with {from_param=}.") return params def get_events_with_offset( self, config_ids: str, offset: str | None = "", limit: int = 20, from_epoch: str = "", ) -> tuple[list[str], str | None]: params = self.prepare_params(offset=offset, limit=limit, from_epoch=from_epoch) raw_response = self.execute_get_events_request(params, config_ids) events: list[str] = raw_response.split("\n") del raw_response # free the full-page response string immediately; we only need the split lines demisto.debug(f"[Get Events] Split response into {len(events)} lines (events + offset context).") offset = None try: if events and events[-1] == "": events.pop() offset_context = events.pop() loaded_offset_context = json.loads(offset_context) offset = loaded_offset_context.get("offset") except Exception as e: demisto.error(f"[Get Events] Couldn't decode offset with {offset_context=}, reason {e}") return events, offset async def get_events_concurrently( self, config_ids: str, offset: str | None = "", limit: int = 200000, from_epoch: str = "", counter: int = 0 ) -> tuple[list[str], str | None]: """Send request to get events from Akamai. Args: config_ids (str): security configuration ids to fetch, e.g. `51000;56080`. offset (str | None): The offset (hash) to use for offset based mechanism. limit (int, optional): The number of events to limit for every request. from_epoch (str): From when to fetch if first time. counter (int, optional): The execution number. Returns: tuple[list[str], str | None]: The events and offset obtained from last request. """ params = self.prepare_params( offset=offset, limit=limit, from_epoch=from_epoch, prefix_msg=f"Running in interval = {counter}. " ) loop = asyncio.get_event_loop() raw_response = await loop.run_in_executor( None, functools.partial( self.execute_get_events_request, config_ids=config_ids, params=params, prefix_msg=f"Running in interval = {counter}. ", ), ) events: list[str] = raw_response.split("\n") new_offset = None try: if events and events[-1] == "": events.pop() offset_context = events.pop() loaded_offset_context = json.loads(offset_context) new_offset = loaded_offset_context.get("offset") except Exception as e: demisto.error(f"Running in interval = {counter}. Couldn't decode offset with {offset_context=}, reason {e}") new_offset = offset return events, new_offset """HELPER FUNCIONS""" def date_format_converter(from_format: str, date_before: str, readable_format: str = "%Y-%m-%dT%H:%M:%SZ%Z") -> str: """ Convert datatime object from epoch time to follow format %Y-%m-%dT%H:%M:%SZ Args: from_format: format to convert from. date_before: date before conversion epoch time or %Y-%m-%dT%H:%M:%SZ format readable_format: readable format by default %Y-%m-%dT%H:%M:%SZ Examples: >>> date_format_converter(from_format='epoch', date_before='1576570098') '2019-12-17T08:08:18Z' >>> date_format_converter(from_format='epoch', date_before='1576570098', readable_format='%Y-%m-%d %H:%M:%S') '2019-12-17 08:08:18' >>> date_format_converter(from_format='readable', date_before='2019-12-17T08:08:18Z') '1576570098' Returns: Converted date as Datetime object or string object """ converted_date: str | int = "" if from_format == "epoch": converted_date = datetime.utcfromtimestamp(int(date_before)).strftime(readable_format) elif from_format == "readable": date_before += "UTC" converted_date = int(datetime.strptime(date_before, readable_format).replace(tzinfo=timezone.utc).timestamp()) # noqa: UP017 return str(converted_date) def decode_message(msg: str) -> Sequence[str | None]: """ Follow these steps for data members that appear within the event's attackData section: 1. If the member name is prefixed rule, URL-decode the value. 2. The result is a series of base64-encoded chunks delimited with semicolons. 3. Split the value at semicolon (;) characters. 4. base64-decode each chunk of split data. The example above would yield a sequence of alert, alert, and deny. Args: msg: Messeage to decode Returns: Decoded message as array Examples: >>> decode_message(msg='ZGVueQ%3d%3d') ['deny'] >>> decode_message(msg='Q3VzdG9tX1JlZ0VYX1J1bGU%3d%3bTm8gQWNjZXB0IEhlYWRlciBBTkQgTm8gVXNlciBBZ2VudCBIZWFkZXI%3d') ['Custom_RegEX_Rule', 'No Accept Header AND No User Agent Header'] """ if not msg: return [] readable_msg = [] translated_msg = urllib.parse.unquote(msg).split(";") for word in translated_msg: word = b64decode(word).decode("utf-8", errors="replace") if word: readable_msg.append(word) return readable_msg def events_to_ec(raw_response: list) -> tuple[list, list, list]: """ Convert raw response response to ec Args: raw_response: events as list from raw response Returns: events as defined entry context and events for human readable """ events_ec: list[dict] = [] ip_ec: list[dict] = [] events_human_readable: list[dict] = [] for event in raw_response: # Hoist the three nested sections once per event to avoid re-evaluating ``event.get(...)`` # dozens of times below. Behavior is unchanged: missing sections still default to {}. attack_data = event.get("attackData", {}) http_message = event.get("httpMessage", {}) geo = event.get("geo", {}) events_ec.append( { "AttackData": assign_params( ConfigID=attack_data.get("configId"), PolicyID=attack_data.get("policyId"), ClientIP=attack_data.get("clientIP"), Rules=decode_message(attack_data.get("rules")), RuleMessages=decode_message(attack_data.get("ruleMessages")), RuleTags=decode_message(attack_data.get("ruleTags")), RuleData=decode_message(attack_data.get("ruleData")), RuleSelectors=decode_message(attack_data.get("ruleSelectors")), RuleActions=decode_message(attack_data.get("ruleActions")), ), "HttpMessage": assign_params( RequestId=http_message.get("requestId"), Start=http_message.get("start"), Protocol=http_message.get("protocol"), Method=http_message.get("method"), Host=http_message.get("host"), Port=http_message.get("port"), Path=http_message.get("path"), RequestHeaders=http_message.get("requestHeaders"), Status=http_message.get("status"), Bytes=http_message.get("bytes"), ResponseHeaders=http_message.get("responseHeaders"), ), "Geo": assign_params( Continent=geo.get("continent"), Country=geo.get("country"), City=geo.get("city"), RegionCode=geo.get("regionCode"), Asn=geo.get("asn"), ), } ) ip_ec.append( assign_params( Address=attack_data.get("clientIP"), ASN=geo.get("asn"), Geo={"Country": geo.get("country")}, ) ) events_human_readable.append( assign_params( **{ "Attacking IP": attack_data.get("clientIP"), "Config ID": attack_data.get("configId"), "Policy ID": attack_data.get("policyId"), "Rules": decode_message(attack_data.get("rules")), "Rule messages": decode_message(attack_data.get("ruleMessages")), "Rule actions": decode_message(attack_data.get("ruleActions")), "Date occured": date_format_converter(from_format="epoch", date_before=http_message.get("start")), "Location": {"Country": geo.get("country"), "City": geo.get("city")}, } ) ) return events_ec, ip_ec, events_human_readable """ COMMANDS """ @logger def test_module_command(client: Client, config_ids: str) -> tuple[None, None, str]: """Performs a basic GET request to check if the API is reachable and authentication is successful. Args: client: Client object with request config_ids: Validated config IDs from params Returns: 'ok' if test successful. Raises: DemistoException: If test failed. """ # Test on the following date Monday, 6 March 2017 16:07:22 events, offset = client.get_events(config_ids=config_ids, from_epoch="1488816442", limit="1") if isinstance(events, list): return None, None, "ok" raise DemistoException(f"Test module failed, {events}") @logger def fetch_incidents_command( client: Client, fetch_time: str, fetch_limit: str | int, config_ids: str, last_run: str | None = None ) -> tuple[list[dict[str, Any]], dict]: """Uses to fetch incidents into Demisto Documentation: https://github.com/demisto/content/tree/master/docs/fetching_incidents Args: client: Client object with request fetch_time: From when to fetch if first time, e.g. `3 days` fetch_limit: limit of incidents in a fetch config_ids: security configuration ids to fetch, e.g. `51000;56080` last_run: Last fetch object occurs. Returns: incidents, new last_run """ raw_response: list | None = [] if not last_run: last_run, _ = parse_date_range(date_range=fetch_time, date_format="%s") raw_response, offset = client.get_events(config_ids=config_ids, from_epoch=last_run, limit=fetch_limit) incidents = [] if raw_response: for event in raw_response: attack_data = event.get("attackData", {}) http_message = event.get("httpMessage", {}) incidents.append( { "name": f"{INTEGRATION_NAME}: {attack_data.get('configId')} - {http_message.get('requestId')}", "occurred": date_format_converter(from_format="epoch", date_before=http_message.get("start")), "rawJSON": json.dumps(event), } ) return incidents, {"lastRun": offset} def get_events_command( client: Client, config_ids: str, offset: str | None = None, limit: str | None = None, from_epoch: str | None = None, to_epoch: str | None = None, time_stamp: str | None = None, ) -> tuple[object, dict, list | dict]: """ Get security events from Akamai WAF service Allowed query parameters combinations: 1. offset - Since a prior request. 2. offset, limit - Since a prior request, limited. 3. from - Since a point in time. 4. from, limit - Since a point in time, limited. 5. from, to - Over a range of time. 6. from, to, limit - Over a range of time, limited. Args: client: Client object config_ids: Unique identifier for each security configuration. To report on more than one configuration, separate integer identifiers with semicolons, e.g. 12892;29182;82912. offset: This token denotes the last message. If specified, this operation fetches only security events that have occurred from offset. This is a required parameter for offset mode and you can't use it in time-based requests. limit: Defines the approximate maximum number of security events each fetch returns, in both offset and time-based modes. The default limit is 20000. Expect requests to return a slightly higher number of security events than you set in the limit parameter, because data is stored in different buckets. from_epoch: The start of a specified time range, expressed in Unix epoch seconds. This is a required parameter to get time-based results for a set time_stamp, and you can't use it in offset mode. to_epoch: The end of a specified time range, expressed in Unix epoch seconds. You can't use this parameter in offset mode and it's an optional parameter in time-based mode. If omitted, the value defaults to the current time. time_stamp: timestamp (