Symantec Endpoint Security

Symantec Endpoint Security Event Collector for Cortex XSIAM.

Analytics & SIEM · Symantec Endpoint Security

Details

IDSymantec Endpoint Security
ProviderBroadcom
CategoryAnalytics & SIEM
From Version6.8.0
Docker Imagedemisto/python3:3.12.13.10116658
Supported ModulesAgentix XSIAM

README

Symantec Endpoint Security Event Collector for Cortex XSIAM.

Configure Symantec Endpoint Security on Cortex XSIAM

  1. Navigate to Settings > Configurations > Data Collection > Automations & Feed Integrations.
  2. Search for Symantec Endpoint Security.
  3. Click Add instance to create and configure a new integration instance.

    Parameter Required
    Server URL True
    OAuth credential True
    Stream ID True
    Channel ID True
    Fetch interval in seconds True
    Use system proxy settings False
    Trust any certificate (not secure) False
  4. Click Test to validate the URLs, token, and connection.

To generate a token for the Token parameter

  1. Log in to the Symantec Endpoint Security console.
  2. Click Integration > Client Applications.
  3. Choose Add Client Application.
  4. Choose a name for the application, then click Add. The client application details screen will appear.
  5. Click and select Client Secret.
  6. Click the ellipsis and select Client Secret.
  7. Click the copy icon next to OAuth Credentials.

For more information on obtaining OAuth Credentials, refer to this documentation or watch this video.

Note:

  • No need to generate the bearer token, the integration uses the provided OAuth Credentials to generate one.
  • The test_module test checks only the validity of the OAuth credential parameter and does not validate the Channel ID and Stream ID parameters.
  • Fetching events that occurred at a specific time may be delayed due to delays in event ingestion on Symantec’s side.

symantec-ses-reset-integration-context


Reset Integration context. By default, resetting the integration context only resets the next_fetch field in the integration context.

Note:
By default, resetting the integration context only resets the next_fetch field in the integration context,
This means that the next fetch call will be performed without the next parameter, but events will still be filtered, preventing duplicate events from being ingested.

When using the delete_all=true argument, the entire integration_context is deleted. This means that the API call will be performed without the next parameter, and duplicate events may be ingested into the system.

Base Command

symantec-ses-reset-integration-context

Input

Argument Name Description Required
delete_all Whether delete all integration context, default, false. Possible values are: true, false. Optional

Context Output

There is no context output for this command.

Troubleshooting

In case where the integration has been disabled for an extended period, it is recommended to run the command symantec-ses-reset-integration-context without the delete_all argument. This will ensure that the next parameter stored in the integration_context is cleared,
This means that the fetch process will run without the next parameter and retrieve all available events from the stream. However, events will still be filtered based on the last retrieved event timestamp.

Configuration parameters

  • host — Server URL (required)
  • token — (required)
  • stream_id — Stream ID (required)
  • channel_id — Channel ID (required)
  • proxy — Use system proxy settings
  • insecure — Trust any certificate (not secure)
  • longRunning — Long Running Instance

Commands (1)

  • symantec-ses-reset-integration-context

    Reset Integration context. By default, resetting the integration context only resets the `next_fetch` field in the integration context.

import time
from datetime import datetime

import demistomock as demisto
from CommonServerPython import *  # noqa # pylint: disable=unused-wildcard-import
from requests import Response

from CommonServerUserPython import *  # noqa

# CONSTANTS
VENDOR = "symantec"
PRODUCT = "endpoint_security"
DEFAULT_CONNECTION_TIMEOUT = 30
MAX_CHUNK_SIZE_TO_READ = 1024 * 1024 * 100  # 100 MB
DATE_FORMAT_WITHOUT_MILLISECOND = "%Y-%m-%dT%H:%M:%SZ"
DATE_FORMAT_WITH_MILLISECOND = "%Y-%m-%dT%H:%M:%S.%fZ"
DELIMITER = b"\n"
MAX_EVENTS_PER_PUSH_XSIAM = 20000
MAX_FETCH_FAILURES_ALLOWED = 5

"""
Sleep time between fetch attempts when an error occurs in the retrieval process,
primarily used to avoid overloading with consecutive API calls
if an error is received from the API.
"""
FETCH_INTERVAL = 60
SLEEP_DURATION_DUE_API_ERROR = FETCH_INTERVAL / 2


class EventCounter:
    def __init__(self):
        self._raw_events = 0
        self._filtered_events = 0
        self._total_bytes: int | float = 0
        self._events_missing_schema_counter: int = 0
        self._event_missing_schema: dict = {}

    @property
    def events(self) -> int:
        return self._raw_events

    @events.setter
    def events(self, value: int):
        self._raw_events += value

    @property
    def filtered_events(self) -> int:
        return self._filtered_events

    @filtered_events.setter
    def filtered_events(self, value: int):
        self._filtered_events += value

    @property
    def total_bytes(self):
        return self._total_bytes

    @total_bytes.setter
    def total_bytes(self, value: int | float):
        self._total_bytes += value

    @property
    def events_missing_schema_counter(self):
        return self._events_missing_schema_counter

    @events_missing_schema_counter.setter
    def events_missing_schema_counter(self, value: int):
        self._events_missing_schema_counter += value

    @property
    def event_missing_schema(self):
        return self._event_missing_schema

    @event_missing_schema.setter
    def event_missing_schema(self, value: dict):
        if self._event_missing_schema:
            return
        self._event_missing_schema = value

    def print_summary(self):
        demisto.debug(
            f"Summary Log:\n"
            f"- Total events received from Symantec (before filtering): {self.events} events\n"
            f"- Total events sent to XSIAM (after filtering): {self.filtered_events} events\n"
            f"- Total data received from Symantec: "
            f"{self.total_bytes} bytes (~{self.total_bytes / (1024 * 1024):.4f} MB)\n"
            f"- Number of events missing a schema: {self.events_missing_schema_counter}\n"
        )
        if self.event_missing_schema:
            demisto.debug(f"Example of an event missing a schema: {self.event_missing_schema}")


""" Exceptions """


class UnauthorizedToken(Exception):
    """
    Exception raised when the authentication token is unauthorized.
    """


class NextPointingNotAvailable(Exception):
    """
    Exception raised when the next pointing is not available.
    """


class EmptyResponse(Exception):
    """
    Exception raised when the response from the API is None
    """


class NoEventsReceived(Exception):
    """
    Exception raised when no events received
    """


class Client(BaseClient):
    def __init__(
        self,
        base_url: str,
        token: str,
        stream_id: str,
        channel_id: str,
        verify: bool,
        proxy: bool,
    ) -> None:
        self.headers: dict[str, str] = {}
        self.token = token
        self.stream_id = stream_id
        self.channel_id = channel_id

        super().__init__(
            base_url=base_url,
            verify=verify,
            proxy=proxy,
            timeout=180,
        )

        self._update_access_token_in_headers()

    def _update_access_token_in_headers(self):
        """
        Retrieves an access token using the `token` provided in the params, and updates `self.headers`.
        """
        get_token_headers: dict[str, str] = {
            "accept": "application/json",
            "content-type": "application/x-www-form-urlencoded",
            "Authorization": f"Basic {self.token}",
        }
        try:
            res = self._http_request(
                "POST",
                url_suffix="/v1/oauth2/tokens",
                headers=get_token_headers,
                data={},
            )
        except Exception as e:
            raise DemistoException("Failed getting an access token") from e

        if "access_token" not in res:
            raise DemistoException(
                f"The key 'access_token' does not exist in response, Response from API: {res}",
                res=res,
            )
        self.headers = {
            "Authorization": f'Bearer {res["access_token"]}',
            "Accept": "application/x-ndjson",
            "Content-Type": "application/json",
            "Accept-Encoding": "gzip",
        }

    def get_events(self, payload: dict[str, str]) -> Response:
        """
        API call in streaming to fetch events
        """
        return self._http_request(
            method="POST",
            url_suffix=f"/v1/event-export/stream/{self.stream_id}/{self.channel_id}",
            json_data=payload,
            params={"connectionTimeout": DEFAULT_CONNECTION_TIMEOUT},
            resp_type="response",
            headers=self.headers,
            stream=True,
            ok_codes=[200, 201, 204],
        )


def sleep_if_necessary(last_run_duration: float) -> None:
    """
    Manages the fetch interval by sleeping if necessary.

    This function calculates the fetch runtime against FETCH_INTERVAL.
    If the runtime is less than the FETCH_INTERVAL time, it will sleep
    for the time difference between FETCH_INTERVAL and the fetch runtime.
    Otherwise, the next fetch will occur immediately.
    """
    fetch_sleep = FETCH_INTERVAL - last_run_duration
    if fetch_sleep > 0:
        demisto.debug(f"Sleeping for {fetch_sleep} seconds")
        time.sleep(fetch_sleep)
        return

    demisto.debug("Not sleeping, next fetch will take place immediately")


def calculate_next_fetch(
    filtered_events: list[dict[str, str]],
    next_hash: str,
    include_last_fetch_events: bool,
    last_integration_context: dict[str, str],
) -> dict[str, Any]:
    """
    Calculate and update the integration context for the next fetch operation.

    - Extracts the time of the latest event
    - Extracts all event IDs with time matching the latest event time
    - If the latest event time matches the latest time from the previous fetch,
      extend the suspected duplicate IDs from the previous fetch.
    - If a push to XSIAM fails, store all events in the `integration_context`
      to be pushed in the next fetch.
    - Update the integration_context

    Args:
        filtered_events (list[dict[str, str]]): A list of filtered events.
        next_hash (str): The hash for the next fetch operation.
        include_last_fetch_events (bool): Flag to include last fetched events in the integration context.
        last_integration_context (dict[str, str]): The previous integration context.
    """

    if filtered_events:
        events_suspected_duplicates, latest_event_time = extract_events_suspected_duplicates(filtered_events)
    else:
        events_suspected_duplicates = []
        latest_event_time = last_integration_context.get("latest_event_time", "")

    if latest_event_time == last_integration_context.get("latest_event_time", ""):
        # If the latest event time matches the previous one,
        # extend the suspected duplicates list with events from the previous context,
        # to control deduplication across multiple fetches.
        demisto.debug(
            "The latest event time equals the latest event time from the previous fetch,"
            " adding the suspect duplicates from last time"
        )
        events_suspected_duplicates.extend(last_integration_context.get("events_suspected_duplicates", []))

    integration_context = {
        "latest_event_time": latest_event_time,
        "events_suspected_duplicates": events_suspected_duplicates,
        "next_fetch": {"next": next_hash} if next_hash else {},
        "last_fetch_events": filtered_events if include_last_fetch_events else [],
    }

    demisto.debug(f"Updating integration context with new data: {integration_context}")
    return integration_context


def push_events(events: list[dict]):
    """
    Push events to XSIAM.
    """
    demisto.debug(f"Pushing {len(events)} to XSIAM")
    send_events_to_xsiam(
        events=events,
        vendor=VENDOR,
        product=PRODUCT,
        chunk_size=XSIAM_EVENT_CHUNK_SIZE_LIMIT,
    )
    demisto.debug(f"Pushed {len(events)} to XSIAM successfully")


def parse_event_time_to_date_time(event: dict = {}, event_time: str = "") -> datetime:
    """
    Parse the event time from the given event dict to datetime object.
    """
    event_time = event.get("log_time", "") if event else event_time

    if not event_time:  # In case the `log_time` key is missing, it is set to the earliest time.
        return datetime.strptime(
            datetime.min.strftime(DATE_FORMAT_WITH_MILLISECOND),
            DATE_FORMAT_WITH_MILLISECOND,
        )

    if "." in event_time:
        event_time = event_time.strip("Z")
        seconds, micro_z = event_time.split(".")
        event_time = seconds + "." + micro_z[:6] + "Z"
    try:
        event_date_time = datetime.strptime(event_time, DATE_FORMAT_WITH_MILLISECOND)
    except Exception as e:
        demisto.debug(f"Failed to parse log_time {event_time} with milliseconds format. Error: {e}")
        try:
            event_date_time = datetime.strptime(event_time, DATE_FORMAT_WITHOUT_MILLISECOND)
        except Exception:
            raise e

    return event_date_time


def extract_events_suspected_duplicates(events: list[dict]) -> tuple[list[str], str]:
    """
    Extract event IDs of potentially duplicate events.

    This function identifies events with the latest timestamp and considers them as
    potential duplicates. It returns a list of their unique identifiers (UUIDs).
    """

    # Find the maximum event time
    latest_event_time: str = max(events, key=parse_event_time_to_date_time).get("log_time", "")
    latest_event_time_obj = parse_event_time_to_date_time(event_time=latest_event_time)

    # Filter all JSONs with the maximum event time
    filtered_events = filter(
        lambda event: parse_event_time_to_date_time(event) == latest_event_time_obj,
        events,
    )

    # Extract the event_ids from the filtered events
    return [event["uuid"] for event in filtered_events if event.get("uuid")], latest_event_time


def is_duplicate(
    event_id: str,
    event_time: datetime,
    latest_event_time: datetime,
    events_suspected_duplicates: set[str],
) -> bool:
    """
    Determine if an event is a duplicate based on its time and ID.

    This function checks if an event is considered a duplicate by comparing its
    timestamp with the latest event time and checking if its ID is in the set of
    suspected duplicates.

    Args:
        event_id (str): The unique identifier of the event.
        event_time (datetime): The timestamp of the event.
        latest_event_time (datetime): The timestamp of the last event from the last fetch.
        events_suspected_duplicates (set): A set of event IDs suspected to be duplicates.

    Returns:
        bool: whether the event's time is earlier than the latest, OR
              (its time is identical to the latest AND
              its id is in the list of suspected duplicates)
    """
    if event_time < latest_event_time:
        return True
    elif event_time == latest_event_time and event_id in events_suspected_duplicates:
        return True
    return False


def filter_duplicate_events(
    events: list[dict[str, str]], integration_context: dict, counter: EventCounter
) -> list[dict[str, str]]:
    """
    Filter out duplicate events from the given list of events.

    Args:
        events (list[dict[str, str]]): A list of event dicts, each containing 'uuid' and 'log_time' keys.

    Returns:
        list[dict[str, str]]: A list of event dicts without fear of duplication.
    """
    events_suspected_duplicates = set(integration_context.get("events_suspected_duplicates", []))
    latest_event_time = integration_context.get("latest_event_time") or datetime.min.strftime(DATE_FORMAT_WITH_MILLISECOND)

    latest_event_time = parse_event_time_to_date_time(event_time=latest_event_time)

    filtered_events: list[dict[str, str]] = []

    demisto.debug(f"The number of events before filtering for the current iteration: {len(events)}")

    for event in events:
        if not event.get("uuid") or not event.get("log_time"):
            event["_time"] = event.get("time", "")
            filtered_events.append(event)
            counter.events_missing_schema_counter = 1
            counter.event_missing_schema = event
            continue
        if not is_duplicate(
            event["uuid"],
            parse_event_time_to_date_time(event),
            latest_event_time,
            events_suspected_duplicates,
        ):
            event["_time"] = event.get("time", "")
            filtered_events.append(event)

    return filtered_events


def get_events(client: Client, next_fetch: dict[str, str], counter: EventCounter):
    events: list[dict] = []
    next_hash: str = ""

    with client.get_events(payload=next_fetch) as res:
        if res is None:
            raise EmptyResponse
        if res.status_code == 204:
            raise NoEventsReceived

        demisto.debug(f"Completed API call with status_code {res.status_code}, proceeding with row iterations.")
        for line in res.iter_lines(chunk_size=MAX_CHUNK_SIZE_TO_READ, delimiter=DELIMITER):
            if not line:
                continue  # Skip empty lines

            counter.total_bytes = len(line)

            json_res = json.loads(line.decode("utf-8"))
            events.extend(json_res.get("events", []))
            next_hash = json_res.get("next", "")

            # If the events exceed the limit, yield them and reset
            if len(events) >= MAX_EVENTS_PER_PUSH_XSIAM:
                yield events, next_hash
                events = []  # Reset events

        # If there are remaining events after the loop
        if events:
            yield events, next_hash

    # Ensure an empty response is yielded if no events were found
    yield [], next_hash


def filtering_and_push_events(events: list[dict], next_hash: str, integration_context: dict, counter: EventCounter):
    counter.events = len(events)
    filtered_events = filter_duplicate_events(events, integration_context, counter)
    counter.filtered_events = len(filtered_events)

    filtered_events.extend(integration_context.get("last_fetch_events", []))

    try:
        push_events(filtered_events)
    except Exception as e:
        # If the push of events to XSIAM fails,
        # The current `integration_context` (before the update) is saved
        # so that the next fetch will retrieve based on the current `fetch_next`.
        set_integration_context(integration_context)
        raise DemistoException("Failed to push events to XSIAM, The integration_context updated") from e

    return calculate_next_fetch(
        filtered_events=filtered_events,
        next_hash=next_hash,
        include_last_fetch_events=False,
        last_integration_context=integration_context,
    )


def get_events_command(client: Client, integration_context: dict[str, Any]) -> dict[str, Any]:
    next_fetch: dict[str, str] = integration_context.get("next_fetch", {})
    counter = EventCounter()
    try:
        for events, next_hash in get_events(client, next_fetch, counter):
            if not events:
                counter.print_summary()
                return integration_context
            integration_context = filtering_and_push_events(events, next_hash, integration_context, counter)
    except DemistoException as e:
        if e.res is not None:
            if e.res.status_code == 401:
                demisto.info("Unauthorized access token, trying to obtain a new access token")
                raise UnauthorizedToken
            if e.res.status_code == 410:
                raise NextPointingNotAvailable
        raise
    except Exception:
        set_integration_context(integration_context)
        raise

    return integration_context


def perform_long_running_loop(client: Client):
    """
    Manages the fetch process.
    Due to a limitation on Symantec's side,
    the integration is configured as long-running
    since API calls can take over 5 minutes.

    Fetch process:
        - In every iteration except the first,
          fetch is performed with the `next_fetch` argument,
          which acts as a pointer for Symantec.
        - When an error is encountered from Symantec,
          it is handled based on the error type, and before the next iteration,
          the process enters a brief sleep period defined by `FETCH_INTERVAL`
          to avoid overloading with API calls.
    """
    while True:
        # Used to calculate the duration of the fetch run.
        start_timestamp = time.time()
        demisto.debug("START FETCH")
        try:
            integration_context = get_integration_context()
            demisto.info(f"Starting new fetch with {integration_context=}")
            integration_context = get_events_command(client, integration_context=integration_context)

            # When the fetch succeeds, the `fetch_failure_count` is reset.
            integration_context.pop("fetch_failure_count", None)
            set_integration_context(integration_context)

        except UnauthorizedToken:
            try:
                client._update_access_token_in_headers()
            except Exception as e:
                raise DemistoException("Failed obtaining a new access token") from e

        except NextPointingNotAvailable:
            demisto.debug(
                "Next is pointing to older event which is not available for streaming. "
                "Clearing next_fetch, The integration's dedup mechanism will make sure we don't insert duplicate events. "
                "We will eventually get a different pointer and fetching will overcome this edge case"
            )
            integration_context.pop("next_fetch", None)
            set_integration_context(integration_context)

        except EmptyResponse:
            demisto.info("Didn't receive any response from streaming endpoint.")

        except NoEventsReceived:
            next_fetch_param = integration_context.get("next_fetch", {})
            demisto.info(f"No Events stream for {next_fetch_param=}")

        except Exception as e:
            calculate_fetch_failure_count()
            sleep_if_necessary(SLEEP_DURATION_DUE_API_ERROR)
            raise DemistoException(f"Failed to fetch logs from API {e}")

        # Used to calculate the duration of the fetch run.
        end_timestamp = time.time()
        demisto.debug("END FETCH")

        sleep_if_necessary(end_timestamp - start_timestamp)


def calculate_fetch_failure_count():
    """
    Calculates and updates the count of consecutive fetch failures

    This function retrieves the current count of consecutive fetch failures from the integration context
    If the count exceeds MAX, it resets the integration context
    """
    integration_context = get_integration_context()
    if (fetch_failure_count := integration_context.get("fetch_failure_count", 0)) > MAX_FETCH_FAILURES_ALLOWED:
        reset_integration_context({})
    else:
        integration_context["fetch_failure_count"] = fetch_failure_count + 1
        set_integration_context(integration_context)


def reset_integration_context(args: dict[str, str]) -> CommandResults:
    integration_context = get_integration_context()
    delete_all = argToBoolean(args.get("delete_all", "false"))
    if delete_all:
        set_integration_context({})
        readable_output = "The integration context was reset successfully."
    else:
        integration_context.pop("next_fetch", None)
        integration_context.pop("fetch_failure_count", None)
        set_integration_context(integration_context)
        readable_output = "The `next_fetch` in integration context was reset successfully."
    return CommandResults(readable_output=readable_output)


def test_module() -> str:
    """
    The test is performed by obtaining the `access_token` during `Client`'s initialization.
    avoiding the use of `test_module` with get_events due to the one-minute timeout
    set for the `test_module` command by the our server.
    """
    return "ok"


def main() -> None:  # pragma: no cover
    params = demisto.params()
    args = demisto.args()

    host = params["host"]
    token = params["token"]["password"]
    stream_id = params["stream_id"]
    channel_id = params["channel_id"]
    verify = not argToBoolean(params.get("insecure", False))
    proxy = argToBoolean(params.get("proxy", False))

    command = demisto.command()
    try:
        client = Client(
            base_url=host,
            token=token,
            stream_id=stream_id,
            channel_id=channel_id,
            verify=verify,
            proxy=proxy,
        )

        if command == "test-module":
            return_results(test_module())
        if command == "symantec-ses-reset-integration-context":
            return_results(reset_integration_context(args))
        if command == "long-running-execution":
            demisto.info("Starting long running execution")
            perform_long_running_loop(client)
        else:
            raise NotImplementedError(f"Command {command} is not implemented.")

    except Exception as e:
        return_error(f"Failed to execute {command} command. Error in Symantec Endpoint Security Integration [{e}].")


""" ENTRY POINT """

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