Proofpoint Email Security Event Collector

Collects events for Proofpoint Email Security using the streaming API.

Analytics & SIEM · Proofpoint Email Security

Details

IDProofpoint Email Security Event Collector
ProviderThoma Bravo
CategoryAnalytics & SIEM
From Version6.9.0
Docker Imagedemisto/netutils:1.0.0.10187688
Supported ModulesXSIAM

README

Collects events for Proofpoint using the streaming API.
This integration was integrated and tested Proofpoint Email Security.

Configure Proofpoint Email Security Event Collector in Cortex

Parameter Description Required
Server URL   True
Cluster ID The user group ID. True
API key   True
Use system proxy settings   False
Fetch Events   False
Event types to fetch Denotes which event type to fetch, if not provided will fetch all kinds. False
Fetch interval in seconds   True

Commands

proofpoint-es-get-last-run-results


Retrieves the results of a connection attempt to Proofpoint, indicating whether it was successful or failed and why. If event fetching has been initiated, this command provides the results of the most recent fetch attempt.

proofpoint-es-get-events


Retrieves events from the Proofpoint Email Security PoD archive for a specified time range to backfill gaps in fetched events. This command is intended for development and debugging purposes and is to be used with caution after consulting with engineering, as it may create duplicate events, exceed API request rate limits, and disrupt the fetch events mechanism. Review the list of known limitations below for additional details.

Base Command

proofpoint-es-get-events

Input

Argument Description Required
since_time The start of the time range to fetch events from. E.g., ‘3 days ago’, ‘2025-01-01T10:00:00’. Rounds down to the nearest hour. Required
to_time The end of the time range to fetch events from. E.g., ‘2 days ago’, ‘2025-01-01T11:00:00’. Rounds up to the nearest hour. Required
timezone_offset The UTC timezone offset in hours to apply to the since_time and to_time arguments. E.g. -5 for UTC-5. Optional
event_types A comma-separated list of event types to fetch. If not provided, all types will be retrieved. Optional
limit The maximum number of events to fetch per specified event type. Optional
should_push_events If true, the command will push the events to the Cortex XSIAM dataset; otherwise, it will only display them. Optional

Context Output

There is no context output for this command.

Known Limitations

  1. The API does not allow use of the same API Key for more than one session at the same time. To open more multiple simultaneous websocket connections to receive the same event type, additional API Key(s) must be generated via the Proofpoint PoD dashboard.
  2. When running the proofpoint-es-get-events command, HTTP 409 (Conflict) errors may be raised if the integration instance has “Fetch Events” enabled. Ensure the “Fetch Events” checkbox is unchecked before triggering this command.
  3. When running the proofpoint-es-get-events command, HTTP 400 (Bad Request) errors may be raised if the time range is older than 30 days. Ensure that both the since_time and to_time arguments are within the last 30 days.

Troubleshooting

In case of data ingestion delays or missing events, it is recommended to configure a separate integration instance per event type.

Configuration parameters

  • host — Server URL (required)
  • cluster_id — Cluster ID (required)
  • api_key — (required)
  • fetch_interval — Fetch interval in seconds (required)
  • longRunning — Long Running Instance
  • proxy — Use system proxy settings
  • event_types — Event types to fetch
  • isFetchEvents — Fetch events

Commands (2)

  • proofpoint-es-get-events

    Retrieves events from the Proofpoint Email Security PoD archive for a specified time range to backfill gaps in fetched events. This command is intended for development and debugging purposes and is to be used with caution after consulting with engineering, as it may create duplicate events, exceed API request rate limits, and disrupt the fetch events mechanism.

  • proofpoint-es-get-last-run-results

    Retrieves the results of a connection attempt to Proofpoint, indicating whether it was successful or failed and why. If event fetching has been initiated, this command provides the results of the most recent fetch attempt.

from contextlib import ExitStack, contextmanager
from functools import partial
from threading import Lock, Thread

import demistomock as demisto
from CommonServerPython import *
from dateutil import tz
from http import HTTPStatus
from websockets import exceptions
from websockets.sync.client import connect
from websockets.sync.connection import Connection
from datetime import datetime, timezone, timedelta

VENDOR = "proofpoint"
PRODUCT = "email_security"

URL = "{host}/v1/stream?cid={cluster_id}&type={type}"


FETCH_INTERVAL_IN_SECONDS = 60
FETCH_SLEEP = 5
SERVER_IDLE_TIMEOUT = 300
MAX_RECONNECT_ATTEMPTS = 5
MAX_BACKOFF_SECONDS = 60
BASE_BACKOFF_SECONDS = 2


EVENT_TYPES = ["message", "maillog", "audit"]
DEFAULT_GET_EVENTS_LIMIT = 10
DATE_FILTER_FORMAT = "%Y-%m-%dT%H:%M:%S%z"
PING_INTERVAL = 60  # Interval between keepalive pings in seconds
PING_TIMEOUT = 120  # Timeout for waiting for a pong reply to a keepalive ping in seconds
OPEN_TIMEOUT = 10  # Timeout for opening the WebSocket connection in seconds
CLOSE_TIMEOUT = 60  # Timeout for closing the connection in seconds
MAX_MESSAGE_SIZE: int | None = None  # Disable the 1 MiB default size limit on incoming messages
RECEIVE_TIMEOUT = 1  # Timeout for receiving events in seconds


class EventConnection:
    def __init__(
        self,
        event_type: str,
        url: str,
        headers: dict,
        fetch_interval: int = FETCH_INTERVAL_IN_SECONDS,
        idle_timeout: int = SERVER_IDLE_TIMEOUT - 20,
        check_heartbeat: bool = True,
    ):
        demisto.info(f"Starting EventConnection of type {event_type}")
        self.event_type = event_type
        self.url = url
        self.headers = headers
        self.lock = Lock()
        self.idle_timeout = idle_timeout
        self.fetch_interval = fetch_interval
        self.connection = self.connect()
        if check_heartbeat:
            self.heartbeat_thread = Thread(target=self.heartbeat, daemon=True)
            self.heartbeat_thread.start()

    def connect(self) -> Connection:
        """
        Establish a new WebSocket connection.

        Parameters are aligned with Proofpoint's official reference client
        (see ``logstream-client.ini`` ``[Websocket]`` section):

        * ``max_size=None`` disables the websockets library's default 1 MiB cap on incoming
          frames. Proofpoint may emit messages larger than 1MB; without this the server tears
          the connection down with code 1011 (internal error) and the client subsequently
          fails to re-establish the TLS session ("[Errno 104] Connection reset by peer").
        * ``ping_timeout=None`` disables pong-reply enforcement, preventing the keep-alive
          watchdog from killing the socket while a large message is being received/decoded.
        * ``ping_interval``, ``open_timeout`` and ``close_timeout`` mirror the reference
          client defaults.
        """
        return connect(
            self.url,
            additional_headers=self.headers,
            open_timeout=OPEN_TIMEOUT,
            close_timeout=CLOSE_TIMEOUT,
            ping_interval=PING_INTERVAL,
            ping_timeout=PING_TIMEOUT,
            max_size=MAX_MESSAGE_SIZE,
        )

    @property
    def is_to_archive(self) -> bool:
        """
        Check if the connection is to an archive.
        """
        return bool("sinceTime" in self.url and "toTime" in self.url)

    def reconnect(self):
        """
        Reconnect logic for the WebSocket connection using an exponential backoff strategy.
        This handles the HTTP 409 error by delaying the retry to allow the Proofpoint server to clear the session.
        """
        attempt = 0
        while attempt < MAX_RECONNECT_ATTEMPTS:
            attempt += 1
            demisto.info(f"[{self.event_type}] Going to attempt reconnection #{attempt} / {MAX_RECONNECT_ATTEMPTS}.")
            try:
                self.connection = self.connect()
                demisto.info(f"[{self.event_type}] Successfully reconnected to WebSocket after {attempt} attempt(s).")
                return

            except exceptions.InvalidStatus as e:
                # Need to wait if HTTP 409 (Conflict) error was raised because the failed session
                # has not been fully terminated or released by the Proofpoint server
                if e.response.status_code == HTTPStatus.CONFLICT:
                    delay = min(MAX_BACKOFF_SECONDS, BASE_BACKOFF_SECONDS * (2 ** (attempt - 1)))
                    demisto.error(
                        f"[{self.event_type}] Reconnection failed due to session conflict. "
                        f"Waiting {delay} seconds before retrying (Attempt {attempt})."
                    )
                    time.sleep(delay)

                else:
                    demisto.error(f"[{self.event_type}] Connection status error: {e!s}.")
                    raise

            except Exception as e:
                demisto.error(f"[{self.event_type}] General reconnection failure: {e!s}.")
                raise

    def heartbeat(self):
        """
        Heartbeat thread function to periodically send keep-alives (pong) to the server.
        Keep-alives are sent regardless of the actual connection activity to ensure the connection remains open.
        """
        while True:
            with self.lock:
                try:
                    self.connection.pong()
                    demisto.info(f"[{self.event_type}] Sent heartbeat pong")
                except exceptions.ConnectionClosedError as e:
                    demisto.error(f"[{self.event_type}] Connection closed due to error in thread - {self.event_type}: {e!s}")
                    self.reconnect()
                except exceptions.ConnectionClosedOK:
                    demisto.info(f"[{self.event_type}] Connection closed OK in thread - {self.event_type}")
                    self.reconnect()
                except Exception as e:
                    demisto.error(f"[{self.event_type}] Unexpected error in heartbeat: {e!s} {traceback.format_exc()}")
                    self.reconnect()
            time.sleep(self.idle_timeout)

    def receive(self, timeout: int = RECEIVE_TIMEOUT) -> dict[str, Any]:
        """Receive a new event from the websocket connection.

        Args:
            timeout (int): Maximum time to wait for a message to be received. Defaults to RECEIVE_TIMEOUT.

        Returns:
            dict[str, Any]: The raw event from the websocket.
        """
        return json.loads(self.connection.recv(timeout=timeout))


def is_interval_passed(fetch_start_time: datetime, fetch_interval: int) -> bool:
    """This function checks if the given interval has passed since the given start time

    Args:
        fetch_start_time (datetime): The start time of the interval
        fetch_interval (int): The interval in seconds

    Returns:
        bool: True if the interval has passed, False otherwise
    """
    return fetch_start_time + timedelta(seconds=fetch_interval) < datetime.utcnow()


def set_the_integration_context(key: str, val: Any):
    """Adds a key-value pair to the integration context dictionary.
    If the key already exists in the integration context, the function will overwrite the existing value with the new one.
    """
    cnx = demisto.getIntegrationContext()
    cnx[key] = val
    demisto.setIntegrationContext(cnx)


@contextmanager
def websocket_connections(
    host: str,
    cluster_id: str,
    api_key: str,
    since_time: str | None = None,
    to_time: str | None = None,
    fetch_interval: int = FETCH_INTERVAL_IN_SECONDS,
    event_types: list[str] = EVENT_TYPES,
    write_to_integration_context: bool = True,
    check_heartbeat: bool = True,
):
    """
    Create a connection for every type of event.

    Args:
        host (str): host URL for the websocket connection.
        cluster_id (str): Proofpoint cluster ID to connect to.
        api_key (str): Proofpoint API key.
        since_time (str): Start time to fetch events from.
        to_time (str): End time for fetch, leave empty for real-time streaming.
        fetch_interval (int): Time between fetch iterations, used for estimating message receive times for idle heartbeat.
        event_types: (list[str]): The list of event types to collect.
        write_to_integration_context (bool): Whether to write events to the integration context.
        check_heartbeat (bool): Whether to check for heartbeat messages.

    Yields:
        list[EventConnection]: List containing an eventConnection for every event type
    """
    demisto.info(
        f"Starting websocket connection to {host} with cluster id: {cluster_id}, sinceTime: {since_time}, toTime: {to_time}"
    )
    url = URL
    if since_time:
        url += f"&sinceTime={since_time}"
    if to_time:
        url += f"&toTime={to_time}"
    url = partial(url.format, host=host, cluster_id=cluster_id)
    extra_headers = {"Authorization": f"Bearer {api_key}"}

    try:
        with ExitStack():  # Keep connection contexts for clean up
            connections = [
                EventConnection(
                    event_type=event_type,
                    url=url(type=event_type),
                    headers=extra_headers,
                    fetch_interval=fetch_interval,
                    check_heartbeat=check_heartbeat,
                )
                for event_type in event_types
            ]

            if write_to_integration_context:
                set_the_integration_context(
                    "last_run_results", f"Opened a connection successfully at {datetime.now().astimezone(tz.tzutc())}"
                )

            yield connections
    except Exception as e:
        if write_to_integration_context:
            set_the_integration_context(
                "last_run_results", f"{e!s} \n This error happened at {datetime.now().astimezone(tz.tzutc())}"
            )
        raise DemistoException(f"{e!s}\n")


def fetch_events(
    connection: EventConnection,
    fetch_interval: int,
    integration_context: dict,
    should_skip_sleeping: List[bool],
    write_to_integration_context: bool = True,
) -> list[dict]:
    """
    This function fetches events from the given connection, for the given fetch interval

    Args:
        connection (EventConnection): the connection to the event type
        fetch_interval (int): Total time to keep fetching before stopping
        integration_context (dict) The integration context dict.
        should_skip_sleeping (List[bool]): a list to update all execution results -
        False if timeout occurred, otherwise will append True.
        write_to_integration_context (bool): Whether to write to integration context.

    Returns:
        list[dict]: A list of events
    """
    event_type = connection.event_type
    demisto.debug(f"[{event_type}] Starting to fetch events.")
    events: list[dict] = []
    event_ids = set()  # For deduplication within the *same* fetch cycle only! (Cannot deduplicate all events from the websocket)
    fetch_start_time = datetime.utcnow()

    demisto.info(f"[{event_type}] Preparing to acquire lock & recv.")
    with connection.lock:
        while not is_interval_passed(fetch_start_time, fetch_interval):
            try:
                event = receive_event(connection, timeout=RECEIVE_TIMEOUT)
                event_id = get_event_id(event)
                if event_id in event_ids:
                    demisto.debug(f"[{event_type}] Already fetched {event_id=}. Skipping duplicate.")
                    continue
                event_ids.add(event_id)
                events.append(event)

            except TimeoutError:
                # This exception typically indicates no new events from websocket stream
                demisto.debug(f"[{event_type}] Timeout while waiting for a new event in stream. Breaking.")
                break

            except exceptions.InvalidStatus as e:
                status_code = e.response.status_code
                demisto.error(f"[{event_type}] Invalid status: {status_code}. Starting recovery.")
                recover_after_disconnection(connection, events, event_ids, reconnect=True)
                continue

            except exceptions.ConnectionClosedError:
                demisto.error(f"[{event_type}] Connection closed with error. Starting recovery.")
                recover_after_disconnection(connection, events, event_ids, reconnect=True)
                continue

            except exceptions.ConnectionClosedOK:
                # This exception from archive websocket means event fetching completed successfully
                if connection.is_to_archive:
                    demisto.info(f"[{event_type}] Connection closed with OK status. All archived events were fetched.")
                    break
                demisto.error(f"[{event_type}] Connection closed with OK status. Starting recovery.")
                recover_after_disconnection(connection, events, event_ids, reconnect=True)
                continue

            except Exception as e:
                demisto.error(f"[{event_type}] Got general error in fetch_events. Starting recovery. Error: {e}")
                recover_after_disconnection(connection, events, event_ids, reconnect=False)
                events.extend(integration_context.get(event_type, []))
                integration_context[event_type] = events  # update events in context in case of fail
                integration_context["last_run_results"] = f"{e!s} \nThe error happened at {datetime.now().astimezone(tz.tzutc())}"
                if write_to_integration_context:
                    set_integration_context(integration_context)
                raise DemistoException(str(e))

    demisto.info(f"[{event_type}] Released the lock and finished recv.")
    num_events = len(events)
    last_event_time = events[-1].get("_time") if events else None
    demisto.debug(f"[{event_type}] Fetched {num_events} events with {last_event_time=}")
    demisto.debug(f"[{event_type}] Fetched events IDs: {', '.join([str(event_id) for event_id in event_ids])}.")
    if write_to_integration_context:
        fetch_end_time = datetime.utcnow()
        set_the_integration_context(
            "last_run_results",
            f"Got from connection {num_events} events starting at {fetch_start_time!s} until {fetch_end_time!s}",
        )
    should_skip_sleeping.append(True)
    return events


def get_event_id(event: dict):
    """
    Extracts event ID using either the `id`, `guid`, or `filter.qid` field.
    Used for id-based deduplication and logging.

    Args:
        event (dict): The raw event.

    Returns:
        str: Event ID.
    """
    return event.get("id") or event.get("guid") or event.get("filter", {}).get("qid")


def receive_event(connection: EventConnection, timeout: int = RECEIVE_TIMEOUT) -> dict[str, Any]:
    """
    Processes a single event by parsing its timestamp and adding metadata.

    Args:
        connection (EventConnection): The connection to the event type.
        timeout (int): Maximum time to wait for a message to be received. Defaults to RECEIVE_TIMEOUT.

    Returns:
        dict[str, Any]: The processed event with '_time' and 'event_type' fields.
    """
    event = connection.receive(timeout=timeout)
    event_id = get_event_id(event)
    event_ts = event.get("ts")
    if not event_ts:
        # if timestamp is not in the response, use the current time
        demisto.debug(f"Event {event_id} does not have a timestamp, using current time.")
        event_ts = datetime.utcnow().isoformat()
    date = dateparser.parse(event_ts)
    if not date:
        demisto.debug(f"Event {event_id} has an invalid timestamp, using current time.")
        # if timestamp is not in correct format, use the current time
        date = datetime.utcnow()
    # the `ts` parameter is not always in UTC, so we need to convert it
    event["_time"] = date.astimezone(tz.tzutc()).isoformat()
    event["event_type"] = connection.event_type
    return event


def receive_events_after_disconnection(connection: EventConnection) -> list[dict]:
    """Receive events after disconnection.

    Args:
        connection (EventConnection): The connection to the event type.

    Returns:
        list[dict]: A list of events received after disconnection.
    """
    events = []
    demisto.info(f"[{connection.event_type}] Trying to receive in-transit events following disconnection.")
    while True:
        try:
            event = receive_event(connection)
            events.append(event)
        except Exception as e:
            demisto.debug(f"[{connection.event_type}] Terminating post-disconnection receive loop after error: {e}")
            break
    demisto.info(f"[{connection.event_type}] Returning {len(events)} in-transit events following disconnection.")
    return events


def recover_after_disconnection(connection: EventConnection, events: list[dict], event_ids: set[str], reconnect: bool = True):
    """Recover after disconnection by attempting to receive in-transit events and (optionally) reconnecting.

    Args:
        connection (EventConnection): The connection to the event type.
        events (list[dict]): The list of events.
        event_ids (set[str]): The set of event ids.
        reconnect (bool, optional): Whether to reconnect. Defaults to True.
    """
    demisto.debug(f"[{connection.event_type}] Recovering after disconnection.")
    in_transit_events = receive_events_after_disconnection(connection)
    for event in in_transit_events:
        event_id = get_event_id(event)
        if event_id in event_ids:
            demisto.debug(f"[{connection.event_type}] Already fetched {event_id=}. Skipping duplicate.")
            continue
        event_ids.add(event_id)
        events.append(event)

    demisto.debug(
        f"[{connection.event_type}] Received {len(in_transit_events)} in-transit events and event ids after disconnection."
    )
    if reconnect:
        demisto.info(f"[{connection.event_type}] Attempting to reconnect after disconnection.")
        try:
            connection.reconnect()
        except Exception as e:
            demisto.error(
                f"[{connection.event_type}] Failed to reconnect after disconnection. Error: {e}. {traceback.format_exc()}"
            )
            raise


def test_module(host: str, cluster_id: str, api_key: str):
    raise DemistoException(
        "No test option is available due to API limitations.\
        To verify the configuration, run the proofpoint-es-get-last-run-results command."
    )


def get_last_run_results_command():
    last_run_results = demisto.getIntegrationContext().get("last_run_results")
    if last_run_results:
        return CommandResults(readable_output=last_run_results)
    else:
        return CommandResults(
            readable_output="No results from the last run yet, \
            please wait one minute and try running the command again."
        )


def perform_long_running_loop(connections: list[EventConnection], fetch_interval: int, should_skip_sleeping: List[bool]):
    """
    Long running loop iteration function. Fetches events from each connection and sends them to XSIAM.

    Args:
        connections (list[EventConnection]): List of connection objects to fetch events from.
        fetch_interval (int): Fetch time per cycle allocated for each event type in seconds.
        should_skip_sleeping (List[bool]): a list to update all execution results -
        False if timeout occurred, otherwise will append True.
    """
    integration_context = demisto.getIntegrationContext()
    events_to_send = []
    for connection in connections:
        events = fetch_events(connection, fetch_interval, integration_context, should_skip_sleeping)
        events.extend(integration_context.get(connection.event_type, []))
        integration_context[connection.event_type] = events  # update events in context in case of fail
        demisto.debug(f"[{connection.event_type}] Adding {len(events)} events to XSIAM")
        events_to_send.extend(events)
    demisto.info(f"Going to send {len(events_to_send)} events to xsiam.")
    # Send the events to the XSIAM, with events from the context
    try:
        if events_to_send:
            send_events_to_xsiam(events_to_send, vendor=VENDOR, product=PRODUCT)
        else:
            demisto.info("No events to send to xsiam.")
        demisto.info(f"Finished sending {len(events_to_send)} events to xsiam, going to clear context.")
        # clear the context after sending the events
        for connection in connections:
            set_the_integration_context(connection.event_type, [])
    except Exception as e:
        demisto.error(f"Failed to send events to XSIAM. Error: {str(e)},  {traceback.format_exc()}")
        # save the events to the context so we can send them again in the next execution
        demisto.setIntegrationContext(integration_context)


def long_running_execution_command(host: str, cluster_id: str, api_key: str, fetch_interval: int, event_types: List[str]):
    """
    Performs the long running execution loop.
    Opens a connection to Proofpoints for every event type and fetches events in a loop.
    Heartbeat threads are opened for every connection to send keepalives if the connection is idle for too long.

    Args:
        host (str): host URL for the websocket connection.
        cluster_id (str): Proofpoint cluster ID to connect to.
        api_key (str): Proofpoint API key.
        fetch_interval (int): Total time allocated per fetch cycle.
        event_types (List[str]): The list of event types to collect.
    """
    support_multithreading()
    demisto.info(f"Starting long running execution. Fetching {event_types=}.")
    while True:
        try:
            with websocket_connections(
                host, cluster_id, api_key, fetch_interval=fetch_interval, event_types=event_types
            ) as connections:
                demisto.info("Connected to websocket")
                fetch_interval = max(1, fetch_interval // len(event_types))  # Divide the fetch interval equally among event types

                while True:
                    should_skip_sleeping: List[bool] = []
                    perform_long_running_loop(connections, fetch_interval, should_skip_sleeping)
                    # sleep for a bit to not throttle the CPU
                    if any(should_skip_sleeping):
                        demisto.info("Finished perform_long_running_loop, should_skip_sleeping evaluated to True.")
                    else:
                        demisto.info(
                            "Finished perform_long_running_loop, should_skip_sleeping evaluated to False,  going to"
                            f"sleep {FETCH_SLEEP} seconds."
                        )
                        time.sleep(FETCH_SLEEP)
                        demisto.info(f"Finished sleeping {FETCH_SLEEP} seconds.")
        except Exception as e:
            err = f"Got an error while running in long running {e}. {traceback.format_exc()}"
            demisto.updateModuleHealth(err, is_error=True)
            demisto.error(err)


def get_events_command(host: str, cluster_id: str, api_key: str, args: dict[str, str]) -> tuple[list, CommandResults]:
    """Implements the `!proofpoint-est-get-events` command.

    Args:
        host (str): The host URL for the websocket connection.
        cluster_id (str): The Proofpoint cluster ID to connect to.
        api_key (str): The Proofpoint API key.
        args (dict[str, str]): The command arguments.

    Returns:
        tuple[list, CommandResults]: The list of events and the command results containing human-readable output.
    """
    event_types = argToList(args.get("event_types")) or EVENT_TYPES
    limit = arg_to_number(args.get("limit")) or DEFAULT_GET_EVENTS_LIMIT

    tz_offset = arg_to_number(args.get("timezone_offset")) or 0
    event_tz = timezone(timedelta(hours=tz_offset))

    # `arg_to_datetime` does not return `None` since args are required. Added `type: ignore` to silence type checkers and linters
    since_time = arg_to_datetime(args.get("since_time"), required=True).replace(tzinfo=event_tz).strftime(DATE_FILTER_FORMAT)  # type: ignore[union-attr]
    to_time = arg_to_datetime(args.get("to_time"), required=True).replace(tzinfo=event_tz).strftime(DATE_FILTER_FORMAT)  # type: ignore[union-attr]

    all_events = []
    time_interval = 1 * 60
    demisto.debug(f"Starting to fetch {event_types=}, {since_time=}, {to_time=}")
    with websocket_connections(
        host,
        cluster_id,
        api_key,
        event_types=event_types,
        since_time=since_time,
        to_time=to_time,
        write_to_integration_context=False,
        check_heartbeat=False,
    ) as connections:
        for connection in connections:
            events = fetch_events(
                connection,
                time_interval,
                integration_context={},
                should_skip_sleeping=[],
                write_to_integration_context=False,
            )
            demisto.debug(f"[{connection.event_type}] Got {len(events)} events. Appending up to {limit} events.")
            all_events.extend(events[:limit])

    return all_events, CommandResults(readable_output=tableToMarkdown(f"Events since {since_time} to {to_time}", all_events))


def main():  # pragma: no cover
    command = demisto.command()
    params = demisto.params()
    args = demisto.args()
    host = params.get("host", "")
    cluster_id = params.get("cluster_id", "")
    api_key = params.get("api_key", {}).get("password", "")
    fetch_interval = int(params.get("fetch_interval", FETCH_INTERVAL_IN_SECONDS))
    event_types = argToList(params.get("event_types")) or EVENT_TYPES
    try:
        if command == "long-running-execution":
            if params.get("isFetchEvents", False):
                demisto.debug("Fetching events is enabled for this instance. Starting long running execution.")
                return_results(long_running_execution_command(host, cluster_id, api_key, fetch_interval, event_types))
            else:
                demisto.debug(f"Fetching events is disabled for this instance. Sleeping for {fetch_interval} seconds.")
                time.sleep(fetch_interval)

        elif command == "test-module":
            return_results(test_module(host, cluster_id, api_key))

        elif command == "proofpoint-es-get-last-run-results":
            return_results(get_last_run_results_command())

        elif command == "proofpoint-es-get-events":
            should_push_events = argToBoolean(args.get("should_push_events", False))
            events, command_results = get_events_command(host, cluster_id, api_key, args)
            if should_push_events:
                send_events_to_xsiam(events, vendor=VENDOR, product=PRODUCT)
            return_results(command_results)

        elif command == "fetch-events":
            # Return informative error message to prevent errors from appearing in tenant UI§
            demisto.info("Fetching events is done via long running execution.")

        else:
            raise NotImplementedError(f"Command {command} is not implemented.")
    except Exception:
        return_error(f"Failed to execute {command} command.\nError:\n{traceback.format_exc()}")


if __name__ in ("__main__", "__builtin__", "builtins"):
    main()