FireEye ETP Event Collector

Use this integration to fetch email security incidents from Trellix Email Security - Cloud as Cortex XSIAM events.

Email · Trellix Email Security - Cloud

Details

IDFireEye ETP Event Collector
ProviderTrellix
CategoryEmail
From Version8.2.0
Docker Imagedemisto/python3:3.12.13.10404775
Supported ModulesAgentix XSIAM

README

Use this integration to fetch email security incidents from Trellix Email Security - Cloud as XSIAM events.

This is the default integration for this content pack when configured by the Data Onboarder in Cortex XSIAM.

Authentication Prerequisites

To ensure a successful connection, you must select the correct authentication method based on the Server URL (Instance URL) you are configuring.

Dual Authentication Methods

We support two different authentication methods depending on the endpoint domain:

Domain Used in Server URL Authentication Method Required Parameters
Ends in trellix.com OAuth 2.0 Client ID, Client Secret, and OAuth Scopes
Ends in fireeye.com API Key API Key (only)


Configure Trellix Email Security - Cloud Event Collector in Cortex

Parameter Description Required
Server URL (e.g., https://etp.us.fireeye.com) List of valid URLs:
US Instance:
https://etp.us.fireeye.com or https://us.etp.trellix.com
EMEA Instance:
https://etp.eu.fireeye.com or https://eu.etp.trellix.com
APJ Instance:
https://etp.ap.fireeye.com or https://ap.etp.trellix.com
USGOV Instance:
https://etp.us.fireeyegov.com
CA Instance:
https://etp.ca.fireeye.com or https://ca.etp.trellix.com
True
Client ID For the Trellix server URL (OAuth). False
Client Secret For the Trellix server URL (OAuth). False
OAuth Scopes For the Trellix server URL (OAuth).
Space-separated list of OAuth scopes.
Note: Only include scopes that your application’s Client ID has already been authorized to use. The full list is: etp.conf.ro etp.trce.rw etp.admn.ro etp.domn.ro etp.accs.rw etp.quar.rw etp.domn.rw etp.rprt.rw etp.accs.ro etp.quar.ro etp.alrt.rw etp.rprt.ro etp.conf.rw etp.trce.ro etp.alrt.ro etp.admn.rw
False
Token URL Override the OAuth 2.0 token endpoint base URL. Leave empty to use the default Trellix IAM endpoint. For Trellix GovCloud tenants, set to https://iam.us.trellix-gov.com. False
API Secret Key For the FireEye server URL. The API Key allows you to integrate with the Trellix Email Security - Cloud. False
Maximum number of Alerts to fetch. The maximum number of Alert events to fetch from Trellix Email Security - Cloud.  
Maximum number of Email Trace to fetch. The maximum number of Email Trace events to fetch from Trellix Email Security - Cloud.  
Maximum number of Activity Log fetch. The maximum number of Activity Log events to fetch from Trellix Email Security - Cloud.  
Trust any certificate (not secure)    
Use system proxy settings    
Fetch outbound traffic Outbound traffic will be fetched in addition to inbound traffic.  
Hide sensitive details from email Hide subject and attachments details from emails.  

Note: If API access permissions are not properly set for the user/role, the authentication attempt will fail with a 400 Client Error: Bad Request even if the Client ID and Secret are otherwise correct.

Access control

All the API requests follow the domain and domain group restrictions of the user. For example, if a user has access to only a few domains in their organization, the response to the APIs will be based on only those domains and domain groups.

REST API Limitation

Email Security — Cloud REST APIs have a rate limit of 60 requests per minute per API route (/trace, /alert, and /quarantine) for every customer.

This means, in 1 minute, a customer can make:

  • 60 requests to Trace APIs (parallel or sequential)
  • 60 requests to Alert APIs (parallel or sequential)
  • 60 requests to Quarantine APIs (parallel or sequential)

Within the minute, the 61st request to any of these APIs would throw a rate limit exceeded error.

The rate limit applies to the customer as a whole. This means that if the customer has multiple admin users who have generated API Keys, the rate limit is applicable at the customer level and not per API key.

Event Direction & “Shared Content” Across Event Types

What you may see

In XSIAM you may notice that the same email content (e.g., subject/message-ID) appears as multiple events, sometimes with different directions (inbound vs outbound) or even in different event types (e.g., both Email Trace and Alert). This is expected:

  • Inbound vs Outbound of the same conversation
    A user receives an email (inbound) and later forwards/replies externally (outbound). Trellix generates two separate events—one per transaction—so both appear in XSIAM.
  • Distribution lists / group expansion
    An inbound message to a list can fan-out and create outbound traffic to external members, yielding additional outbound events.
  • Internal mail
    Some environments also produce “internal/domain-internal” transactions scanned by the gateway. (See note below about the direction_source field.)

How this collector annotates direction

To make the direction explicit in XSIAM, the collector adds a synthetic field:

Field Applies to Values Notes
direction_source Alerts and Email Trace events inbound or outbound Derived from the API route being fetched.
(not set) Activity Log events Activity logs are user activity, not message transit, so no direction is attached.

Important: direction_source reflects the collector source (inbound vs outbound feeds). If your tenant emits “internal” email_trace transactions, that native notion of “internal” is not surfaced via direction_source and should be inferred from the raw payload fields (e.g., sender/recipient domains) if required.

“Shared content” across event types

A single email can legitimately produce:

  • An Email Trace record (transport/flow metadata), and
  • An Alert record (security finding on that message).

These are different event types describing different aspects of the same email. The collector does not deduplicate across event types; it only deduplicates within each type per fetch window. Plan downstream correlation accordingly (e.g., join by message identifiers, subject, envelope addresses, and timestamp buckets, plus direction_source).

Commands

You can execute these commands from the CLI, as part of an automation, or in a playbook. After you successfully execute a command, a DBot message appears in the War Room with the command details.

fireeye-etp-get-events


Gets events from Trellix Email Security - Cloud.

Base Command

fireeye-etp-get-events

Input

Argument Name Description Required
limit The number of events to return. Default is 10. Optional
since_time The start time by which to filter events. Date format will be the same as in the first_fetch parameter. Default is 3 days. Optional
should_push_events Set this argument to True in order to create events, otherwise the command will only display them. Possible values are: true, false. Default is false. Required

Output Notes

  • Additional Fields Added by Collector
    • direction_source (for Alerts and Email Trace only): "inbound" or "outbound".
    • Not present for Activity Log events.
  • Correlation Guidance
    • Expect multiple events representing the same email content across directions and/or event types. Correlate using message identifiers (when present), subject, envelope sender/recipient, time window, and direction_source.

Configuration parameters

  • url — Server URL (e.g., https://etp.us.fireeye.com or https://us.etp.trellix.com). (required)
  • oauth_credentials — Client ID (OAuth)
  • oauth_scopes — OAuth Scopes (OAuth)
  • token_url — Token URL
  • credentials
  • alerts_max_fetch — Maximum number of Alerts to fetch.
  • email_trace_max_fetch — Maximum number of Email Trace to fetch.
  • activity_log_max_fetch — Maximum number of Activity Log fetch.
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings
  • outbound_traffic — Fetch outbound traffic
  • hide_sensitive — Hide sensitive details from email

Commands (1)

  • fireeye-etp-get-events

    Gets events from Trellix Email Security - Cloud. This command is used for developing/ debugging and is to be used with caution, as it can create events, leading to events duplication and API request limitation exceeding.

import base64
import dateparser
import demistomock as demisto
import time
import urllib3
from CommonServerPython import *

# Disable insecure warnings
urllib3.disable_warnings()


""" CONSTANTS """

VENDOR = "fireeye"
PRODUCT = "etp"

EVENTS_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"  # for "_TIME" field in the events dataset
ACTIVITY_LOG_DATE_FORMAT = "%Y-%m-%dT%H:%M:%S%zZ"  # for "from" and "to" time filters in user activity log API

LOG_LINE = f"{VENDOR}_{PRODUCT}:"
DEFAULT_FIRST_FETCH = "3 days"
DEFAULT_MAX_FETCH = 1000
CALCULATED_MAX_FETCH = 5000
DEFAULT_LIMIT = 10
DEFAULT_URL = "https://etp.us.fireeye.com"
DEFAULT_TOKEN_URL = "https://auth.trellix.com/auth/realms/IAM/protocol/openid-connect/token"
TOKEN_URL_SUFFIX = "/iam/v1.0/token"
DATEPARSER_SETTINGS = {
    "RETURN_AS_TIMEZONE_AWARE": True,
    "TIMEZONE": "UTC",
}

""" Fetch Events Classes"""
LAST_RUN = "Last Run"


class EventType:
    def __init__(
        self,
        name: str,
        max_fetch: int,
        api_request_max: int = DEFAULT_MAX_FETCH,
        **kwargs,
    ) -> None:
        self.name = name
        self.client_max_fetch = max_fetch
        self.api_max = api_request_max
        for name, val in kwargs.items():
            self.__setattr__(str(name), val)


EVENT_TYPES = [
    EventType("email_trace", 300, outbound=False, api_request_max=300),
    EventType("activity_log", 500, api_request_max=500),
    EventType("alerts", 200, outbound=False, api_request_max=200),
]
OUTBOUND_EVENT_TYPES = [
    EventType("email_trace_outbound", 300, outbound=True, api_request_max=300),
    EventType("alerts_outbound", 200, outbound=False, api_request_max=200),
]
ALL_EVENTS = EVENT_TYPES + OUTBOUND_EVENT_TYPES

""" CLIENT """


class Client(BaseClient):  # pragma: no cover
    def __init__(
        self,
        base_url: str,
        verify_certificate: bool,
        proxy: bool,
        client_id: str = "",
        client_secret: str = "",
        scope: str = "",
        api_key: str = "",
        outbound_traffic: bool = False,
        hide_sensitive: bool = False,
        token_url: str = "",
    ) -> None:
        super().__init__(base_url, verify_certificate, proxy)
        self.client_id = client_id
        self.client_secret = client_secret
        self.scope = scope
        self.api_key = api_key
        self.outbound_traffic = outbound_traffic
        self.hide_sensitive = hide_sensitive
        self.token_url = urljoin(token_url, TOKEN_URL_SUFFIX) if token_url else DEFAULT_TOKEN_URL
        self.access_token = ""

        # Set up headers based on authentication method
        self._headers = {"Content-Type": "application/json"}

        auth_method = get_authentication_method(client_id, client_secret, api_key)
        if auth_method == "oauth2":
            demisto.debug(f"{LOG_LINE} Using OAuth2 authentication (Client ID/Secret)")
            self.access_token = self._get_valid_oauth_token()
            self._headers["Authorization"] = f"Bearer {self.access_token}"
        elif auth_method == "api_key":
            demisto.debug(f"{LOG_LINE} Using API Key authentication")
            self._headers["x-fireeye-api-key"] = self.api_key

    def _get_valid_oauth_token(self) -> str:
        """
        Get a valid OAuth access token, reusing cached token if still valid.
        Returns the access token or raises ValueError if authentication fails.
        """
        context = get_integration_context()
        cached_token = context.get("access_token", "")
        token_expiry = context.get("token_expiry", 0)

        # Check if cached token is still valid (with 60-second buffer)
        current_time = time.time()
        is_expired = current_time >= (token_expiry - 60)

        if cached_token and not is_expired:
            demisto.debug(f"{LOG_LINE} Using cached OAuth token")
            return cached_token

        # Need to fetch new token
        demisto.debug(f"{LOG_LINE} Fetching new OAuth token")
        return self._fetch_oauth_token()

    def _fetch_oauth_token(self) -> str:
        """
        Fetch a new OAuth access token using this client instance and cache it in integration context.
        Returns the access token or raises ValueError if authentication fails.
        """
        try:
            token_url = self.token_url

            credentials = f"{self.client_id}:{self.client_secret}"
            encoded_credentials = base64.b64encode(credentials.encode()).decode()

            auth_data = {"grant_type": "client_credentials", "scope": self.scope}

            response = requests.post(
                token_url,
                headers={"Content-Type": "application/x-www-form-urlencoded", "Authorization": f"Basic {encoded_credentials}"},
                data=auth_data,
                verify=self._verify,
            )
            response.raise_for_status()

            result = response.json()
            access_token = result.get("access_token", "")
            expires_in = result.get("expires_in", 600)  # Default to 10 minutes

            if not access_token:
                raise ValueError("Failed to obtain access token from OAuth2 authentication")

            # Cache token in integration context
            token_expiry = int(time.time()) + int(expires_in)
            set_integration_context({"access_token": access_token, "token_expiry": token_expiry})

            demisto.debug(f"{LOG_LINE} OAuth2 authentication successful, token cached")
            return access_token

        except Exception as e:
            demisto.error(f"{LOG_LINE} OAuth2 authentication failed: {str(e)}")
            raise ValueError(f"OAuth2 authentication failed: {str(e)}")

    def get_alerts(self, from_LastModifiedOn: str, size: int, outbound: bool = False) -> dict:
        req_body = assign_params(
            traffic_type="outbound" if outbound else "inbound",
            fromLastModifiedOn=from_LastModifiedOn,
            size=size,
        )
        demisto.debug(f"{LOG_LINE} request sent: {from_LastModifiedOn=},{size=}, {outbound=}, {req_body=} ")
        res = self._http_request(method="POST", url_suffix="/api/v1/alerts", json_data=req_body)
        return res

    def get_email_trace(self, from_LastModifiedOn: str, size: int, outbound: bool = False) -> dict:
        req_body = assign_params(
            traffic_type="outbound" if outbound else "inbound",
            size=size,
            attributes=assign_params(
                lastModifiedDateTime={
                    "value": f"{from_LastModifiedOn}",
                    "filter": ">=",
                },
            ),
        )
        demisto.debug(f"{LOG_LINE} request sent: {from_LastModifiedOn=},{size=}, {outbound=}, {req_body=} ")

        res = self._http_request(method="POST", url_suffix="/api/v1/messages/trace", json_data=req_body)

        return res

    def get_activity_log(self, from_LastModifiedOn: str, size: int, to_LastModifiedOn: str) -> dict:
        time = {"from": from_LastModifiedOn}
        if to_LastModifiedOn:
            time["to"] = to_LastModifiedOn
        else:
            # API requires "to" time
            demisto.debug(f"{LOG_LINE} empty to_LastModifiedOn. Setting to now.")
            utc_now = datetime.now(timezone.utc)
            time["to"] = datetime.strftime(utc_now, ACTIVITY_LOG_DATE_FORMAT)

        req_body = assign_params(
            size=size,
            attributes=assign_params(time=time),
        )

        return self._http_request(
            method="POST",
            url_suffix="/api/v1/users/activitylogs/search",
            json_data=req_body,
        )


class LastRun:
    class LastRunEvent:
        def __init__(self, start_time: datetime | None = None, last_ids: set[str] | None = None) -> None:
            self.last_ids = last_ids if last_ids else set()
            self.last_run_timestamp = start_time if start_time else datetime.now()

        def to_demisto_last_run(self) -> dict:
            return {
                "last_fetch_timestamp": self.last_run_timestamp.isoformat(),
                "last_fetch_last_ids": list(self.last_ids),
            }

        def set_ids(self, ids: set[str] = set()) -> None:
            self.last_ids = set(ids) if isinstance(ids, list) else ids

    def __init__(
        self,
        event_types: list | None = None,
        start_time: datetime | None = None,
        last_ids: set | None = None,
    ) -> None:
        self.event_types = event_types if event_types else []
        if event_types:
            for event_type in event_types:
                setattr(self, event_type.name, self.LastRunEvent(start_time, last_ids))

    def get_last_run_event(self, event_name: str) -> LastRunEvent:
        return self.__getattribute__(event_name)

    def to_demisto_last_run(self) -> dict:
        if not self.event_types:
            return {}
        data = {
            LAST_RUN: {
                event_type.name: self.__getattribute__(event_type.name).to_demisto_last_run() for event_type in self.event_types
            }
        }
        return data

    def add_event_type(
        self,
        event_type: str,
        start_time: datetime,
        last_ids: set,
        event_types: list[EventType],
    ) -> None:
        setattr(self, event_type, self.LastRunEvent(start_time, last_ids))
        event_type_from_str = next(filter(lambda x: x.name == event_type, event_types))
        self.event_types.append(event_type_from_str)


def get_last_run_from_dict(data: dict, event_types: list[EventType]) -> LastRun:
    new_last_run = LastRun()
    demisto.debug(f"{LOG_LINE} - Starting to parse last run from server: {data.get(LAST_RUN, 'Missing Last Run key')!s}")

    for event_type in data.get(LAST_RUN, {}):
        demisto.debug(f"{LOG_LINE} - Parsing {event_type=}")

        time = datetime.fromisoformat(data[LAST_RUN].get(event_type, {}).get("last_fetch_timestamp"))
        ids = set(data[LAST_RUN].get(event_type, {}).get("last_fetch_last_ids", []))
        demisto.debug(f"{LOG_LINE} - found id and timestamp in data, adding. \n {ids=}, {time=}")

        new_last_run.add_event_type(event_type, time, ids, event_types)

    demisto.debug(f"{LOG_LINE} - last run was loaded successfully.")

    return new_last_run


class EventCollector:
    def __init__(self, client: Client, events_to_run_on: None | list[EventType] = None) -> None:
        self.client = client
        self.event_types_to_run_on = events_to_run_on if events_to_run_on else []

    def fetch_command(self, demisto_last_run: dict, first_fetch: None | datetime = None):
        events: list = []

        if not demisto_last_run:  # First fetch
            first_fetch = first_fetch if first_fetch else datetime.now()
            demisto.debug(f"{LOG_LINE} First fetch recognized, setting first_datetime to {first_fetch}")
            next_run = LastRun(self.event_types_to_run_on, start_time=first_fetch, last_ids=set())

        else:
            demisto.debug(f"{LOG_LINE} previous fetch recognized. Loading demisto_last_run")

            next_run = get_last_run_from_dict(demisto_last_run, self.event_types_to_run_on)

            #  Getting new events
            demisto.debug(f"{LOG_LINE} Getting new events")

            for event_type in self.event_types_to_run_on:
                demisto.debug(f"{LOG_LINE} getting events of type {event_type.name}")
                if event_type.client_max_fetch > 0:
                    next_run, new_events = self.get_events(event_type=event_type, last_run=next_run)
                    # annotate direction for non-activity events
                    if event_type.name != "activity_log":
                        direction = "outbound" if event_type in OUTBOUND_EVENT_TYPES else "inbound"
                        for evt in new_events:
                            evt.setdefault("direction_source", direction)
                    events += new_events

        demisto.debug(f"{LOG_LINE} fetched {len(events)} to load. Setting last_run")

        next_run_dict = next_run.to_demisto_last_run()
        demisto.debug(f"{next_run_dict=}")

        return next_run_dict, events

    def get_events_command(self, start_time: datetime):
        events = []
        demisto.debug(f"{LOG_LINE}: running get-command")
        for event_type in self.event_types_to_run_on:
            if event_type.client_max_fetch > 0:
                _, new_events = self.get_events(
                    event_type=event_type,
                    last_run=LastRun(self.event_types_to_run_on, start_time, last_ids=set()),
                )
                events += new_events

        hr = tableToMarkdown(name="Test Event", t=events)
        return events, CommandResults(readable_output=hr)

    def fetch_alerts(self, event_type: EventType, start_time: datetime, fetched_ids: set = set()) -> tuple[list[dict], datetime]:
        res_count = 0
        res: list[dict] = []
        results_left = True
        iso_start_time = parse_date_for_api_3_digits(start_time)

        #  Running as long as we have not reached the amount of event or the time frame requested.
        while results_left and res_count < event_type.client_max_fetch:
            demisto.debug(f"{LOG_LINE} getting alerts: {results_left=}, {res_count=}, {start_time=}")
            current_batch = self.client.get_alerts(
                iso_start_time,
                min(event_type.api_max, event_type.client_max_fetch - res_count),
                self.client.outbound_traffic,
            )
            current_batch_data = current_batch.get("data", []) or []
            demisto.debug(f"{LOG_LINE} got {len(current_batch_data)} alerts from API")
            if current_batch_data:
                dedup_data = list(
                    filter(
                        lambda item: item.get("id") not in fetched_ids,
                        current_batch_data,
                    )
                )

                if dedup_data:
                    demisto.debug(
                        f"Fetching {len(dedup_data)} alerts from {len(current_batch_data)} found in API for {event_type.name}"
                    )
                    res.extend(dedup_data)
                    res_count += len(dedup_data)
                    fetched_ids = fetched_ids.union({item.get("id") for item in dedup_data})
                    # Getting last item's modification date, assuming asc order
                    iso_start_time = current_batch["meta"]["fromLastModifiedOn"]["end"]
                else:
                    results_left = False
            else:
                results_left = False

        return res, parse_special_iso_format(iso_start_time)

    def fetch_activity_log(self, event_type: EventType, start_time: datetime, fetched_ids: set = set()) -> tuple[list[dict], str]:
        res = []

        results_left = True
        iso_end_time = ""
        demisto.debug(f"Converting {start_time=} to string")
        # formatting to iso z format without microseconds due to api lack of support,
        # api response should be already in this format.
        iso_start_time = datetime.strftime(start_time.astimezone(timezone.utc), ACTIVITY_LOG_DATE_FORMAT)

        while results_left and ((not iso_end_time) or iso_end_time >= iso_start_time):
            demisto.debug(f"{LOG_LINE} getting user activity: {results_left=}, {iso_start_time=}, {iso_end_time=}")
            current_batch = self.client.get_activity_log(
                from_LastModifiedOn=iso_start_time,
                size=event_type.api_max,
                to_LastModifiedOn=iso_end_time,
            )
            current_batch_data = current_batch.get("data", [])

            if current_batch_data:
                dedup_data = [item for item in current_batch_data if get_activity_log_id(item) not in fetched_ids]
                if dedup_data:
                    demisto.debug(
                        f"Fetching {len(dedup_data)} non duplicates alerts from\
                            {len(current_batch_data)} found in API for {event_type.name}"
                    )
                    res.extend(dedup_data)

                    fetched_ids = fetched_ids.union({get_activity_log_id(item) for item in dedup_data})

                    # Last run of pagination, avoiding endless loop if all page has the same time.
                    # We do not have other eay to handle this case.
                    if iso_end_time == iso_start_time:
                        demisto.debug("Got equal start and end time, this was the last page.")
                        results_left = False

                    # Getting last item's modification date, Assuming Asc order.
                    # We have to format as the response are not have to be in invalid format
                    end_time = parse_special_iso_format(dedup_data[-1]["attributes"]["time"])
                    iso_end_time = datetime.strftime(end_time.astimezone(timezone.utc), ACTIVITY_LOG_DATE_FORMAT)

                else:
                    demisto.debug("Avoiding infinite loop due to multiple alerts in the same time blocking pagination.")
                    results_left = False
            else:
                results_left = False

        # Got all results from API, taking only the last #max_limit.
        if len(res) > event_type.client_max_fetch:
            res = res[-event_type.client_max_fetch :]

        # Getting last_run_time
        next_run = res[0]["attributes"]["time"] if res else iso_start_time

        return res, next_run

    def fetch_email_trace(
        self, event_type: EventType, start_time: datetime, fetched_ids: set = set()
    ) -> tuple[list[dict], datetime]:
        res_count = 0
        res = []

        # getting start time, formatting to 3 digit's microseconds.
        iso_start_time = parse_date_for_api_3_digits(start_time)

        results_left = True

        while results_left and res_count < event_type.client_max_fetch:
            demisto.debug(f"{LOG_LINE} getting trace: {results_left=}, {res_count=}, {start_time=}")

            current_batch = self.client.get_email_trace(
                iso_start_time,
                min(event_type.api_max, event_type.client_max_fetch - res_count),
                self.client.outbound_traffic,
            )
            current_batch_data = current_batch.get("data", []) or []

            if current_batch_data:
                dedup_data = list(
                    filter(
                        lambda item: item.get("id") not in fetched_ids,
                        current_batch_data,
                    )
                )
                if dedup_data:
                    demisto.debug(
                        f"Fetching {len(dedup_data)} alerts from {len(current_batch_data)} \
                            found in API for {event_type.name}"
                    )
                    res.extend(dedup_data)
                    res_count += len(dedup_data)

                    fetched_ids = fetched_ids.union({item.get("id") for item in dedup_data})

                    # Getting last item's modification date, assuming asc order
                    iso_start_time = current_batch["meta"]["fromLastModifiedOn"]["end"][:-1]
                else:
                    results_left = False
            else:
                results_left = False

        return res, parse_special_iso_format(iso_start_time)

    def get_events(self, event_type: EventType, last_run: LastRun) -> tuple[LastRun, list]:
        last_fetched_ids = last_run.get_last_run_event(event_type.name).last_ids
        last_fetch_time: datetime = last_run.get_last_run_event(event_type.name).last_run_timestamp
        # if not last_fetch_time.microsecond:
        demisto.debug(f"{LOG_LINE} {last_fetch_time=}, {last_fetched_ids=}")

        match event_type.name:
            case "alerts" | "alerts_outbound":
                events, last_run_time = self.fetch_alerts(
                    event_type=event_type,
                    start_time=last_fetch_time,
                    fetched_ids=last_fetched_ids,
                )
                last_run_ids: set[str] = {
                    item.get("id", "")
                    for item in filter(
                        lambda item: datetime.fromisoformat(item["attributes"]["meta"]["last_modified_on"]) == last_run_time,
                        events,
                    )
                }
                format_alerts(events, self.client.hide_sensitive)

            case "email_trace" | "email_trace_outbound":
                events, last_run_time = self.fetch_email_trace(
                    event_type=event_type,
                    start_time=last_fetch_time,
                    fetched_ids=last_fetched_ids,
                )
                last_run_ids = {
                    item.get("id", "")
                    for item in filter(
                        lambda item: datetime.fromisoformat(item["attributes"]["lastModifiedDateTime"]) == last_run_time,
                        events,
                    )
                }

                format_email_trace(events, self.client.hide_sensitive)

            case "activity_log":
                events, next_run_time = self.fetch_activity_log(
                    event_type=event_type,
                    start_time=last_fetch_time,
                    fetched_ids=last_fetched_ids,
                )
                last_run_ids = {
                    get_activity_log_id(item) for item in events if demisto.get(item, "attributes.time") == next_run_time
                }

                format_activity_log(events)
                last_run_time = parse_special_iso_format(next_run_time)
            case _:
                raise DemistoException("Event's type format is undefined.")

        if event_type.name != "activity_log":
            direction = "outbound" if event_type in OUTBOUND_EVENT_TYPES else "inbound"
            for evt in events:
                evt["direction_source"] = direction

        demisto.debug(f"{LOG_LINE} Got {len(events)} events to load with type {event_type.name}. Setting last_run")
        last_run.get_last_run_event(event_type.name).set_ids(last_run_ids)
        last_run.get_last_run_event(event_type.name).last_run_timestamp = last_run_time

        return last_run, events


def get_activity_log_id(event: dict) -> str:
    return f"{demisto.get(event, 'attributes.user_action')}- \
            {demisto.get(event, 'attributes.user_email_id')}- \
            {demisto.get(event, 'attributes.time')}"


def set_events_max(event_names: list[str], new_max: int) -> None:
    events_to_update = list(filter(lambda x: x.name in event_names, ALL_EVENTS))
    for event_type in events_to_update:
        event_type.client_max_fetch = new_max


def parse_special_iso_format(datetime_str: str) -> datetime:
    """
    This API returns invalid date string that 'supports' ISO Z, such as: 2023-08-01T14:15:26+0000Z
    In reality, ISO Z should be able to handle microseconds and contains 'Z' *OR* ±00:00,
    and even that is used wrong (aka ±0000 instead of ±00:00)
    This function takes the not ISO-like string and converts it to datetime.
    It supports both existing and non-existing microseconds.
    """

    def fix_date_format(datetime_str: str):
        """ " Gets a time string according to the API standard, fix it and parse it.

        Args:
            datetime_str (str): A string representing time (Might be ISO, ISO Z).

        Raises:
            DemistoException: _description_

        Returns:
            datetime: A datetime object parsed from the fixed datetime string.
        """
        tz_index = None
        demisto.debug(f"Fixing format of datetime string: {datetime_str}.")
        if datetime_str.endswith("Z") and "+" in datetime_str:
            datetime_str = datetime_str[:-1]
            tz_index = datetime_str.find("+")

        if "." in datetime_str:
            decimal_index = datetime_str.find(".")
            # getting length of milliseconds part, as API only return with 'Z' of both tz and 'Z'.
            end_index = tz_index if tz_index else len(datetime_str) - 1

            if len(datetime_str[decimal_index + 1 : end_index]) < 6:
                datetime_str = f"{datetime_str[:decimal_index+1]}000{datetime_str[decimal_index+1:]}"
        date_obj = dateparser.parse(datetime_str, settings={"TIMEZONE": "UTC"})

        if not date_obj:
            demisto.debug(f"Failed to parse date after changes: {datetime_str}")
            raise DemistoException("Failed parsing date. Check logs for more information.")
        return date_obj

    try:
        date_obj = dateparser.parse(datetime_str, settings={"TIMEZONE": "UTC"})
        date_obj = date_obj if date_obj else fix_date_format(datetime_str)

        # The API sometimes returns dates without full data, causing the parsing to fail.
        return date_obj

    except Exception as e:
        demisto.debug(f"Failed parsing {datetime_str}. Error={e!s}.")
        raise e


def parse_date_for_api_3_digits(date_to_parse: datetime) -> str:
    """
    Returns str representation the API can deal with.
    """
    demisto.debug(f"Parsing {date_to_parse=} to API format")
    # getting start time, formatting to 3 digit's microseconds.
    iso_start_time_splitted = date_to_parse.isoformat().split(".")

    # Dealing with 3 digit AND 6 digit microseconds if exists
    # since .123 is .000123 in ISO.
    # If no microseconds found, add .000 instead
    micro_sec = str(int(iso_start_time_splitted[1]))[:3] if len(iso_start_time_splitted) == 2 else "000"
    return f"{iso_start_time_splitted[0]}.{micro_sec}"


""" FORMAT FUNCTION """


def format_alerts(events: list[dict], hide_sensitive: bool):
    for event_data in events:
        create_time = datetime.fromisoformat(event_data["attributes"]["meta"].get("last_modified_on"))

        if create_time:
            event_data["_ENTRY_STATUS"] = (
                "modified" if datetime.fromisoformat(event_data["attributes"]["alert"].get("timestamp")) < create_time else "new"
            )
            event_data["_TIME"] = create_time.isoformat()
        else:
            demisto.info("API response corrupted, no value found in attributes.meta.last_modified_on.")
        event_data["event_type"] = "alert"

        if hide_sensitive:
            if demisto.get(event_data, "attributes.email.attachment"):
                event_data["attributes"]["email"]["attachment"] = "hidden data"

            if demisto.get(event_data, "attributes.email.headers.subject"):
                event_data["attributes"]["email"]["headers"]["subject"] = "hidden data"


def format_email_trace(events: list[dict], hide_sensitive: bool):
    for event_data in events:
        create_time = datetime.fromisoformat(event_data["attributes"].get("lastModifiedDateTime"))
        if create_time:
            event_data["_ENTRY_STATUS"] = (
                "modified" if datetime.fromisoformat(event_data["attributes"].get("acceptedDateTime")) < create_time else "new"
            )
            event_data["_TIME"] = create_time.isoformat()
        else:
            demisto.info("API response corrupted, no value found in attributes.meta.last_modified_on.")

        event_data["event_type"] = "trace"

        if hide_sensitive:
            if event_data.get("included"):
                event_data["included"] = "hidden data"

            if demisto.get(event_data, "attributes.subject"):
                event_data["attributes"]["subject"] = "hidden data"


def format_activity_log(events: list[dict]):
    for event_data in events:
        event_time = event_data["attributes"]["time"]
        # Removing Z letter and adding ":" to get valid iso format
        valid_event_time = f"{event_time[:-3]}:{event_time[-3:-1]}"
        create_time = datetime.fromisoformat(valid_event_time)
        event_data["_TIME"] = create_time.strftime(EVENTS_DATE_FORMAT) if create_time else None
        event_data["event_type"] = "activity"


def test_module(client: Client, events_to_run_on: list[EventType]):
    try:
        collector = EventCollector(client, events_to_run_on)
        for event_type in collector.event_types_to_run_on:
            event_type.client_max_fetch = 1
            collector.get_events(
                event_type=event_type,
                last_run=LastRun(
                    events_to_run_on,
                    datetime.now() - timedelta(minutes=1),
                    last_ids=set(),
                ),
            )
        return "ok"
    except DemistoException as e:
        if e.res.status_code == 500:  # type: ignore
            return "Request to API failed, Please check your credentials"
        else:
            raise


def _get_max_events_to_fetch(params_max_fetch: str | int, arg_limit: str | int) -> int:
    """Gets the maximum number of events to fetch, supporting limit of 0.

    Checks the configured max fetch specific to the log type, and the limit argument from a command.
    If a limit argument exists, it will override the max_fetch.
    If neither are found, uses the default limit.

    Args:
        params_max_fetch (str): A limit for the log type.
        arg_limit (str): A general limit.

    Returns:
        int: The maximum number of events to fetch.

    Raises:
        ValueError: If the limit is not a valid integer.
    """
    try:
        limit_param = DEFAULT_MAX_FETCH if params_max_fetch in ["", None] else int(params_max_fetch)

        limit_arg = None if arg_limit in ["", None] else int(arg_limit)
        val = limit_arg if limit_arg is not None else limit_param

        return int(val)

    except ValueError:
        raise ValueError("Please provide a valid integer value for a fetch limit.")


def validate_authentication_params(client_id: str, client_secret: str, api_key: str, scope: str) -> None:
    """
    Validate authentication parameters.

    Args:
        client_id: The Client ID for OAuth2 authentication.
        client_secret: The Client Secret for OAuth2 authentication.
        api_key: The API Key.
        scope: The space-separated OAuth Scopes.

    Raises: ValueError if authentication configuration is invalid or over-configured.
    """
    has_client_id = bool(client_id)
    has_client_secret = bool(client_secret)
    has_api_key = bool(api_key)
    has_scopes = bool(scope)

    # CHECK FOR AMBIGUOUS OVER-CONFIGURATION
    if has_client_id and has_client_secret and has_api_key:
        raise ValueError(
            "Both OAuth2 (Client ID/Secret) and API Key were provided. " "Please configure only one authentication method."
        )

    # OAUTH2 VALIDATION
    if has_client_id or has_client_secret:
        # Check for incomplete OAuth2 configuration
        if has_client_id and not has_client_secret:
            raise ValueError(
                "Client ID provided but Client Secret is missing. "
                "Both Client ID and Client Secret are required for OAuth2 authentication."
            )

        if has_client_secret and not has_client_id:
            raise ValueError(
                "Client Secret provided but Client ID is missing. "
                "Both Client ID and Client Secret are required for OAuth2 authentication."
            )

        # Check for required SCOPES when using OAuth2
        if not has_scopes:
            raise ValueError(
                "Client ID and Client Secret provided, but the 'OAuth Scopes' parameter is missing. "
                "Scopes are required for OAuth2 authentication."
            )
        return

    # NO AUTHENTICATION METHOD PROVIDED
    if not has_api_key:
        raise ValueError("No authentication credentials provided.")


def get_authentication_method(client_id: str, client_secret: str, api_key: str) -> str:
    """
    Determine which authentication method to use based on provided credentials.

    Args:
        client_id: The Client ID for OAuth2 authentication.
        client_secret: The Client Secret for OAuth2 authentication.
        api_key: The API Key.

    Returns: 'oauth2' for Client ID/Secret, 'api_key' for API Key
    """
    if client_id and client_secret:
        demisto.debug(f"{LOG_LINE} Authentication: Using OAuth2 (Client ID/Secret)")
        return "oauth2"
    elif api_key:
        demisto.debug(f"{LOG_LINE} Authentication: Using API Key")
        return "api_key"
    else:
        raise ValueError("No authentication credentials provided.")


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

    # Extract authentication parameters - prioritize OAuth2 over legacy API Key
    client_id = params.get("oauth_credentials", {}).get("identifier", "")
    client_secret = params.get("oauth_credentials", {}).get("password", "")
    scope = params.get("oauth_scopes", "etp.conf.ro etp.rprt.ro").strip()
    api_key = params.get("credentials", {}).get("password", "")
    token_url = params.get("token_url", "").strip()
    base_url = params.get("url", "").rstrip("/")
    verify = not params.get("insecure", False)
    proxy = params.get("proxy", False)
    outbound_traffic = argToBoolean(params.get("outbound_traffic", False))
    hide_sensitive = argToBoolean(params.get("hide_sensitive", True))

    # Validate authentication configuration
    validate_authentication_params(client_id, client_secret, api_key, scope)

    last_run = demisto.getLastRun()

    command = demisto.command()
    demisto.info(f"Command being called is {command}")
    try:
        # setting the max fetch on each event type
        set_events_max(
            ["alerts", "alerts_outbound"],
            _get_max_events_to_fetch(params.get("alerts_max_fetch", DEFAULT_MAX_FETCH), args.get("limit", "")),
        )
        set_events_max(
            ["email_trace", "email_trace_outbound"],
            _get_max_events_to_fetch(
                params.get("email_trace_max_fetch", DEFAULT_MAX_FETCH),
                args.get("limit", ""),
            ),
        )
        set_events_max(
            ["activity_log"],
            _get_max_events_to_fetch(
                params.get("activity_log_max_fetch", DEFAULT_MAX_FETCH),
                args.get("limit", ""),
            ),
        )

        client = Client(
            base_url=base_url,
            verify_certificate=verify,
            proxy=proxy,
            client_id=client_id,
            client_secret=client_secret,
            scope=scope,
            api_key=api_key,
            outbound_traffic=outbound_traffic,
            hide_sensitive=hide_sensitive,
            token_url=token_url,
        )

        events_to_run_on = EVENT_TYPES + OUTBOUND_EVENT_TYPES if outbound_traffic else EVENT_TYPES
        collector = EventCollector(client, events_to_run_on)
        demisto.debug(f"{LOG_LINE} events configured: {[e.name for e in events_to_run_on]}")

        demisto.debug(f"Command being called is {command}")
        if command == "test-module":
            # This is the call made when pressing the integration Test button.
            result = test_module(client, collector.event_types_to_run_on)
            return_results(result)

        elif command == "fireeye-etp-get-events":
            should_push_events = argToBoolean(args.pop("should_push_events", ""))
            first_fetch_time = arg_to_datetime(arg=params.get("first_fetch", "30 days"), required=True)
            assert isinstance(first_fetch_time, datetime)

            events, results = collector.get_events_command(start_time=first_fetch_time)
            return_results(results)

            if should_push_events:
                send_events_to_xsiam(events, vendor=VENDOR, product=PRODUCT)

        elif command == "fetch-events":
            last_run = demisto.getLastRun()
            next_run, events = collector.fetch_command(demisto_last_run=last_run)
            demisto.debug(f"{events=}")
            send_events_to_xsiam(events, VENDOR, PRODUCT)
            demisto.setLastRun(next_run)

        else:
            raise NotImplementedError

    # Log exceptions and return errors
    except Exception as e:
        return_error(f"Failed to execute {command} command.\nError:\n{e!s}")


if __name__ in ("__main__", "__builtin__", "builtins"):  # pragma: no cover
    main()