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 json
from datetime import UTC, datetime, timedelta

import FireEyeETPEventCollector
import pytest
from freezegun import freeze_time


def util_load_json(path):
    with open(path, encoding="utf-8") as f:
        return json.loads(f.read())


LAST_RUN_MULTIPLE_EVENT = {
    "Last Run": {
        "alerts": {
            "last_fetch_last_ids": ["a", "b"],
            "last_fetch_timestamp": "2023-07-19T12:37:00.028000",
        },
        "email_trace": {
            "last_fetch_last_ids": [],
            "last_fetch_timestamp": "2023-07-19T12:20:00.020000",
        },
        "activity_log": {
            "last_fetch_last_ids": [],
            "last_fetch_timestamp": "2023-07-19T12:20:00.020000",
        },
    }
}
LAST_RUN_ONE_EVENT = {
    "Last Run": {
        "alerts": {
            "last_fetch_last_ids": ["a", "b"],
            "last_fetch_timestamp": "2023-07-19T12:37:00.028000",
        },
    }
}

LAST_RUN_EMPTY: dict = {}
LAST_RUN_DICT_CASES = [
    (
        LAST_RUN_MULTIPLE_EVENT,  # case when multiple events exists.
        [
            FireEyeETPEventCollector.EventType("alerts", 25, outbound=False),
            FireEyeETPEventCollector.EventType("email_trace", 25, outbound=False),
            FireEyeETPEventCollector.EventType("activity_log", 25, outbound=False),
        ],
        LAST_RUN_MULTIPLE_EVENT,
    ),  # expected
    (
        LAST_RUN_ONE_EVENT,  # case when only one event exists
        [FireEyeETPEventCollector.EventType("alerts", 25, outbound=False)],
        LAST_RUN_ONE_EVENT,
    ),  # expected
]


@pytest.mark.parametrize("last_run_dict, event_types_to_run, expected", LAST_RUN_DICT_CASES)
def test_last_run(last_run_dict, event_types_to_run, expected):
    """
    Given: mocked last run dictionary and events to fetch
    When: trying to fetch events
    Then: validate last run creation and save.
    """
    last_run = FireEyeETPEventCollector.get_last_run_from_dict(last_run_dict, event_types_to_run)
    assert len(last_run.event_types) == len(expected.get("Last Run", {}))
    assert {e.name for e in event_types_to_run} - set(last_run.__dict__.keys()) == set()
    new_dict = last_run.to_demisto_last_run()
    assert new_dict["Last Run"].keys() == expected["Last Run"].keys()


def mock_client():
    return FireEyeETPEventCollector.Client(
        base_url="test.com",
        verify_certificate=False,
        proxy=False,
        api_key="api-key",
        outbound_traffic=False,
        hide_sensitive=True,
    )


@freeze_time("2023-07-18T11:34")
@pytest.mark.parametrize(
    "hide_sensitive, alert_expected, trace_expected, activity_expected",
    (
        pytest.param(
            True,
            "formatted_response_hidden_true",
            "formatted_response_hidden_true",
            "formatted_response",
            id="Hide sensitive",
        ),
        pytest.param(
            False,
            "formatted_response_hidden_false",
            "formatted_response_hidden_false",
            "formatted_response",
            id="Do not hide sensitive",
        ),
    ),
)
def test_fetch_alerts(mocker, hide_sensitive, alert_expected, trace_expected, activity_expected):
    """
    Given: mocked client, mocked responses and expected event structure,
    When: fetching incidents
    Then: Testing the formatted events are as required, and direction_source is set for non-activity events.
    """
    client = mock_client()
    client.hide_sensitive = hide_sensitive
    mocked_alert_data = util_load_json("test_data/alerts.json")
    mocked_trace_data = util_load_json("test_data/email_trace.json")
    mocked_activity_data = util_load_json("test_data/activity_log.json")
    event_types_to_run = [
        FireEyeETPEventCollector.EventType("alerts", 25, outbound=False),
        FireEyeETPEventCollector.EventType("email_trace", 200, outbound=False),
        FireEyeETPEventCollector.EventType("activity_log", 2, outbound=False),
    ]
    collector = FireEyeETPEventCollector.EventCollector(client, event_types_to_run)
    mocker.patch.object(
        FireEyeETPEventCollector.Client,
        "get_alerts",
        side_effect=[mocked_alert_data["ok_response_single_data"], {"data": []}],
    )
    mocker.patch.object(
        FireEyeETPEventCollector.Client,
        "get_email_trace",
        side_effect=[mocked_trace_data["ok_response_single_data"], {"data": []}],
    )
    mocker.patch.object(
        FireEyeETPEventCollector.Client,
        "get_activity_log",
        side_effect=[mocked_activity_data["ok_response"], {"data": []}],
    )
    next_run, events = collector.fetch_command(
        demisto_last_run=LAST_RUN_MULTIPLE_EVENT,
        first_fetch=datetime.now(),
    )

    # Validate new direction_source field
    assert events[0].get("direction_source") == "inbound"  # alerts
    assert events[1].get("direction_source") == "inbound"  # email_trace
    assert "direction_source" not in events[2]  # activity_log

    # Compare payloads ignoring direction_source for non-activity events
    alert_cmp = dict(events[0])
    alert_cmp.pop("direction_source", None)
    trace_cmp = dict(events[1])
    trace_cmp.pop("direction_source", None)

    assert alert_cmp == mocked_alert_data[alert_expected]
    assert trace_cmp == mocked_trace_data[trace_expected]
    assert events[2] == mocked_activity_data[activity_expected]


FAKE_ISO_DATE_CASES = [
    (
        "2023-08-01T14:15:26.123456+0000Z",  # 6 digit milliseconds + tz+ Z
        datetime(2023, 8, 1, 14, 15, 26, 123456, tzinfo=UTC),
    ),
    (
        "2023-08-01T14:15:26+0000Z",  # No milliseconds + tz+ Z
        datetime(2023, 8, 1, 14, 15, 26, tzinfo=UTC),
    ),
    (
        "2023-08-01T14:15:26+0000",  # 6 digit milliseconds + tz , No Z
        datetime(2023, 8, 1, 14, 15, 26, tzinfo=UTC),
    ),
    (
        "2023-08-01 14:15:26+0000Z",  # missing 'T'
        datetime(2023, 8, 1, 14, 15, 26, tzinfo=UTC),
    ),
    (
        "2023-08-01T14:15:26Z",  # No milliseconds + tz+ Z
        datetime(2023, 8, 1, 14, 15, 26, tzinfo=UTC),
    ),
    (
        "2023-08-01T14:15:26.123Z",  # 3 digit milliseconds + Z
        datetime(2023, 8, 1, 14, 15, 26, 123000, tzinfo=UTC),
    ),
    (
        "2023-08-01T14:15:26.123+0000Z",  # 3 digit milliseconds + tz+ Z
        datetime(2023, 8, 1, 14, 15, 26, 123, tzinfo=UTC),
    ),
    ("2023-11-07T09:00", datetime(2023, 11, 7, 9, 0)),  # No seconds
]


@pytest.mark.parametrize("input_str, expected_dt", FAKE_ISO_DATE_CASES)
def test_parse_special_iso_format(input_str, expected_dt):
    """
    Given: date string in differents formats
    When: trying to convert from response to datetime
    Then: make sure parsing is correct.
    """
    assert FireEyeETPEventCollector.parse_special_iso_format(input_str) == expected_dt


class TestLastRun:
    @pytest.fixture
    def last_run(self):
        """
        Given: event_typess
        When: trying to create last run
        Then: make sure last run created with the events.
        """
        # Create a LastRun instance with dummy event types
        event_types = [
            FireEyeETPEventCollector.EventType("alerts", 25, outbound=False),
            FireEyeETPEventCollector.EventType("email_trace", 25, outbound=False),
            FireEyeETPEventCollector.EventType("activity_log", 25, outbound=False),
        ]
        return FireEyeETPEventCollector.LastRun(event_types=event_types)

    def test_to_demisto_last_run_empty(self, last_run):
        # Test to_demisto_last_run method when there are no event types.
        last_run.event_types = []
        assert last_run.to_demisto_last_run() == {}


@freeze_time("2023-07-30 11:34:30")
def test_get_command(mocker):
    """
    Given: mocked client and responses
    When: running get-events command
    Then: markdown is returned and direction_source is set on non-activity events.
    """
    mocked_alert_data = util_load_json("test_data/alerts.json")
    mocked_trace_data = util_load_json("test_data/email_trace.json")
    mocked_activity_data = util_load_json("test_data/activity_log.json")
    event_types_to_run = [
        FireEyeETPEventCollector.EventType("alerts", 25, outbound=False),
        FireEyeETPEventCollector.EventType("email_trace", 1000, outbound=False),
        FireEyeETPEventCollector.EventType("activity_log", 25, outbound=False),
    ]
    collector = FireEyeETPEventCollector.EventCollector(mock_client(), event_types_to_run)
    mocker.patch.object(
        FireEyeETPEventCollector.Client,
        "get_alerts",
        side_effect=[mocked_alert_data["ok_response_single_data"], {"data": []}],
    )
    mocker.patch.object(
        FireEyeETPEventCollector.Client,
        "get_email_trace",
        side_effect=[mocked_trace_data["ok_response_single_data"], {"data": []}],
    )
    mocker.patch.object(
        FireEyeETPEventCollector.Client,
        "get_activity_log",
        side_effect=[mocked_activity_data["ok_response"], {"data": []}],
    )
    events, md = collector.get_events_command(start_time=datetime.now() - timedelta(days=20))
    assert md.readable_output

    # Validate direction_source flags in the returned events list
    assert events[0].get("direction_source") == "inbound"  # alerts
    assert events[1].get("direction_source") == "inbound"  # email_trace
    assert "direction_source" not in events[2]  # activity_log


PAGINATION_CASES = [
    (
        "activity_log",
        "test_data/activity_log.json",
        "get_activity_log",
        3,
    ),  # 2 calls of activity log (4 events, one dup)
    (
        "alerts",
        "test_data/alerts.json",
        "get_alerts",
        3,
    ),  # 2 calls of alerts (4 events, one dup)
    (
        "email_trace",
        "test_data/email_trace.json",
        "get_email_trace",
        3,
    ),  # 2 calls of trace (4 events, one dup)
]


@freeze_time("2023-08-02 11:34:30")
@pytest.mark.parametrize("event_name, res_mock_path, func_to_mock, expected_res", PAGINATION_CASES)
def test_pagination(mocker, event_name, res_mock_path, func_to_mock, expected_res):
    """
    Given: a Mocked response of calls to API
    When: Running fetch on activity log type
    Then: Validate we fetch correct number of results, meaning:
        1. No dups
        2. All events arrived
        3. direction_source is set only for non-activity events
    """
    collector = FireEyeETPEventCollector.EventCollector(
        mock_client(),
        [FireEyeETPEventCollector.EventType(event_name, 4, outbound=False)],
    )

    mocked_data = util_load_json(res_mock_path)
    mocker.patch.object(
        FireEyeETPEventCollector.Client,
        func_to_mock,
        side_effect=mocked_data["paging_response"] + [{"data": []}],
    )

    # using timedelta with milliseconds due to a freeze_time issue.
    events, md = collector.get_events_command(start_time=datetime.now() - timedelta(days=2, milliseconds=1))
    assert len(events)
    if event_name != "activity_log":
        assert all(e.get("direction_source") == "inbound" for e in events)
    else:
        assert all("direction_source" not in e for e in events)


@pytest.mark.parametrize(
    "max_fetch, limit_args, expected",
    [
        pytest.param(
            "",
            None,
            FireEyeETPEventCollector.DEFAULT_MAX_FETCH,
            id="both empty, using default",
        ),
        pytest.param(0, 10, 10, id="empty configuration, args override"),
        pytest.param(10, 0, 0, id="existing configuration, empty args override"),
        pytest.param(50, None, 50, id="param overrides default"),
        pytest.param(0, None, 0, id="param stay empty on purpose"),
        pytest.param(80, 75, 75, id="args overrides param"),
        pytest.param("", "invalid", None, id="limit invalid"),
        pytest.param("a", "", None, id="configured max_fetch invalid"),
    ],
)
def test_get_max_events_to_fetch(max_fetch, limit_args, expected):
    """
    Given: max_fetch and limit_args parameters
    When: setting the max events to fetch
    then: calculate the max events to fetch based on the parameters passed in
    """
    if expected is None:
        with pytest.raises(ValueError):
            FireEyeETPEventCollector._get_max_events_to_fetch(max_fetch, limit_args)
    else:
        assert FireEyeETPEventCollector._get_max_events_to_fetch(max_fetch, limit_args) == expected


@pytest.mark.parametrize(
    "input_dt,expected",
    [
        (datetime(2023, 1, 15, 14, 30, 45, 123456), "2023-01-15T14:30:45.123"),
        (datetime(2023, 1, 15, 14, 30, 45, 123), "2023-01-15T14:30:45.123"),
        (datetime(2023, 1, 15, 14, 30, 45), "2023-01-15T14:30:45.000"),
    ],
)
def test_parse_date_for_api_3_digits(input_dt, expected):
    output = FireEyeETPEventCollector.parse_date_for_api_3_digits(input_dt)
    assert output == expected


@pytest.mark.parametrize("event_names,new_max", [(["alerts"], 50), (["email_trace"], 100)])
def test_set_events_max_multiple(mocker, event_names, new_max):
    """Test setting client max fetch for multiple events.

    Given
        - A list of event names
        - A new max fetch value

    When
        - Calling set_events_max with the event names and new max

    Then
        - The client_max_fetch should be updated for those events
    """
    FireEyeETPEventCollector.set_events_max(event_names, new_max)

    for name in event_names:
        event = next(e for e in FireEyeETPEventCollector.ALL_EVENTS if e.name == name)
        assert event.client_max_fetch == new_max


def test_limit_zero_skip_fetch_flow(mocker):
    """
    Given: 2 events with limit set to zero and one event with actual number.
    When: running the fetch flow
    Then: validates the flow was only running for the event with limit.
    """
    event_types = [
        FireEyeETPEventCollector.EventType("alerts", 0, outbound=False),
        FireEyeETPEventCollector.EventType("email_trace", 0, outbound=False),
        FireEyeETPEventCollector.EventType("activity_log", 25, outbound=False),
    ]
    last_run = FireEyeETPEventCollector.LastRun(event_types=event_types)
    collector = FireEyeETPEventCollector.EventCollector(mock_client(), event_types)
    get_events_mock = mocker.patch.object(collector, "get_events", return_value=(last_run, []))
    mocker.patch.object(FireEyeETPEventCollector.LastRun, "to_demisto_last_run", return_value={})

    collector.fetch_command(demisto_last_run=LAST_RUN_MULTIPLE_EVENT)
    assert get_events_mock.call_count == 1

    get_events_mock.reset_mock()
    collector.get_events_command(start_time=datetime(2023, 1, 15, 14, 30, 45, 123000))
    assert get_events_mock.call_count == 1


@freeze_time("2025-07-07T17:00:00Z")
def test_client_get_activity_log(mocker):
    """
    Given:
        - "from" time and a "size".
    When:
        - Calling Client.get_activity_log.
    Then:
        - Assert "to" time is set as expected due to API requirement (current UTC time).
    """
    client = mock_client()
    mock_http_request = mocker.patch.object(client, "_http_request")

    from_time = "2025-07-07T08:22:22+0000Z"
    size = 100
    client.get_activity_log(from_LastModifiedOn=from_time, to_LastModifiedOn="", size=size)

    expected_to_time = "2025-07-07T17:00:00+0000Z"  # Current UTC time (frozen timestamp)
    assert mock_http_request.call_args.kwargs == {
        "method": "POST",
        "url_suffix": "/api/v1/users/activitylogs/search",
        "json_data": {"size": size, "attributes": {"time": {"from": from_time, "to": expected_to_time}}},
    }


@pytest.mark.parametrize(
    "client_id, client_secret, api_key, scopes, expected_exception",
    [
        # Case 1: SUCCESS - Full OAuth2 configuration
        ("id", "secret", "", "scope", None),
        # Case 2: SUCCESS - API Key configuration
        ("", "", "key", "", None),
        # Case 3: FAILURE - Ambiguous Over-Configuration (OAuth2 + API Key)
        ("id", "secret", "key", "scope", r"Both OAuth2 \(Client ID/Secret\) and API Key were provided\..*"),
        # Case 4: FAILURE - Incomplete OAuth2 (Missing Scopes)
        ("id", "secret", "", "", r".*'OAuth Scopes' parameter is missing\..*"),
        # Case 5: FAILURE - Incomplete OAuth2 (Missing Secret)
        ("id", "", "", "scope", r"Client ID provided but Client Secret is missing\..*"),
        # Case 6: FAILURE - Incomplete OAuth2 (Missing ID)
        ("", "secret", "", "scope", r"Client Secret provided but Client ID is missing\..*"),
        # Case 7: FAILURE - No credentials provided
        ("", "", "", "", r"No authentication credentials provided\."),
    ],
)
def test_validate_authentication_params(client_id, client_secret, api_key, scopes, expected_exception):
    """
    Given:
        - A set of authentication parameters (client ID, client secret, API key, scopes).
    When:
        - Calling the validate_authentication_params function.
    Then:
        - Ensure the function validates successfully (returns None) for valid configurations,
          OR
        - Ensure the function raises the expected ValueError for invalid or over-configured parameters.
    """
    from FireEyeETPEventCollector import validate_authentication_params

    if expected_exception:
        with pytest.raises(ValueError, match=expected_exception):
            validate_authentication_params(client_id, client_secret, api_key, scopes)
    else:
        # Should not raise an exception and returns None
        result = validate_authentication_params(client_id, client_secret, api_key, scopes)
        assert result is None


@pytest.mark.parametrize(
    "token_url_init, expected_url",
    [
        # Default URL when no override is configured (empty string -> DEFAULT_TOKEN_URL)
        (
            "",
            "https://auth.trellix.com/auth/realms/IAM/protocol/openid-connect/token",
        ),
        # GovCloud override — user provides base URL, Client.__init__ uses urljoin to append suffix
        (
            "https://iam.us.trellix-gov.com",
            "https://iam.us.trellix-gov.com/iam/v1.0/token",
        ),
    ],
)
def test_client_fetch_oauth_token_uses_configured_url(mocker, token_url_init, expected_url):
    """
    Given:
        - A Client instance with a configured token_url (empty for default, or GovCloud base URL).
    When:
        - Calling _fetch_oauth_token on the Client.
    Then:
        - Ensure the OAuth token request is sent to the expected resolved URL.
    """
    # Mock _get_valid_oauth_token to prevent real token fetch during __init__
    mocker.patch.object(
        FireEyeETPEventCollector.Client,
        "_get_valid_oauth_token",
        return_value="init_token",
    )

    client = FireEyeETPEventCollector.Client(
        base_url="https://test.com",
        verify_certificate=True,
        proxy=False,
        client_id="test_id",
        client_secret="test_secret",
        scope="etp.alrt.ro",
        token_url=token_url_init,
    )

    mock_response = mocker.MagicMock()
    mock_response.json.return_value = {"access_token": "test_token", "expires_in": 600}
    mock_response.raise_for_status.return_value = None
    mock_post = mocker.patch("requests.post", return_value=mock_response)
    mocker.patch("FireEyeETPEventCollector.set_integration_context")

    token = client._fetch_oauth_token()

    assert token == "test_token"
    mock_post.assert_called_once()
    actual_url = mock_post.call_args[0][0]
    assert actual_url == expected_url


@pytest.mark.parametrize(
    "client_id, client_secret, api_key, expected_result, expected_exception",
    [
        # Case 1: SUCCESS - OAuth2 configuration
        ("id", "secret", "", "oauth2", None),
        # Case 2: SUCCESS - API Key configuration
        ("", "", "key", "api_key", None),
        # Case 3: FAILURE - No credentials provided
        ("", "", "", None, r"No authentication credentials provided\."),
    ],
)
def test_get_authentication_method(client_id, client_secret, api_key, expected_result, expected_exception):
    """
    Given:
        - A set of authentication credentials (client ID, client secret, API key).
    When:
        - Calling the get_authentication_method function.
    Then:
        - Ensure the function returns the expected authentication method ('oauth2' or 'api_key'),
          OR
        - Ensure the function raises ValueError when no credentials are provided.
    """
    from FireEyeETPEventCollector import get_authentication_method

    if expected_exception:
        with pytest.raises(ValueError, match=expected_exception):
            get_authentication_method(client_id, client_secret, api_key)
    else:
        assert get_authentication_method(client_id, client_secret, api_key) == expected_result