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
| ID | FireEye ETP Event Collector |
|---|---|
| Provider | Trellix |
| Category | |
| From Version | 8.2.0 |
| Docker Image | demisto/python3:3.12.13.10404775 |
| Supported Modules | Agentix 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 thedirection_sourcefield.)
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_sourcereflects the collector source (inbound vs outbound feeds). If your tenant emits “internal” email_trace transactions, that native notion of “internal” is not surfaced viadirection_sourceand 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.
- 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
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 URLcredentials—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 settingsoutbound_traffic— Fetch outbound traffichide_sensitive— Hide sensitive details from email
Commands (1)
-
fireeye-etp-get-eventsGets 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()