CiscoETDConnector
Cisco Secure Email Threat Defense (ETD) is a cloud-native email security service that detects and remediates email threats such as phishing, business email compromise, and malware. This collector ingests ETD message, audit, and blocked connection logs into Cortex XSIAM.
Analytics & SIEM · Cisco ETD Connector
Details
| ID | CiscoETDConnector |
|---|---|
| Provider | Cisco |
| Category | Analytics & SIEM |
| From Version | 6.8.0 |
| Docker Image | demisto/fastapi:0.125.0.12689358 |
| Supported Modules | XSIAM |
README
Cisco ETD Connector
The Cisco ETD Connector ingests Cisco Secure Email Threat Defense (ETD) message, audit, and blocked connection logs into Cortex XSIAM for security analytics, monitoring, and threat investigation.
This integration uses the Cisco Secure Email Threat Defense Log Export API.
How Collection Works
Cisco ETD does not return events directly. Each API request returns pre-signed download links to hourly export files, which the collector then downloads and parses. This has a few consequences worth knowing:
- Events become available on an hourly basis, and export files for a given hour continue to be generated for up to 20 minutes after that hour ends. The collector therefore re-reads the two most recent hours and skips export files it has already downloaded.
- The hour currently in progress cannot be retrieved. Expect events to appear in Cortex XSIAM with a delay of up to roughly 90 minutes after they occur in Cisco ETD.
- Log retention in Cisco ETD is 30 days. Events older than that cannot be recovered.
- On the first fetch, only the most recent completed hour is collected. Historical data is not backfilled; use
cisco-etd-get-eventsto retrieve a specific past time range.
Prerequisites
Log export must be enabled in the Cisco ETD UI before any events are returned. Navigate to Administration > Business > Export Log Preferences and select the log types you want to collect. Export begins 15 minutes after enabling connection and audit logs, and 20 minutes after enabling message event logs.
Blocked connection logs require Inline Mode and an ETD Advantage license.
Configure Cisco Email Threat Defense Connector in Cortex
| Parameter | Description | Required |
|---|---|---|
| ETD API Base URL | Regional base URL of the Cisco ETD API, for example https://api.us.etd.cisco.com. |
Yes |
| ETD API Key | API key used to authenticate requests to Cisco ETD. | Yes |
| Client ID | Cisco ETD OAuth Client ID. | Yes |
| Client Secret | Cisco ETD OAuth Client Secret. | Yes |
| Trust any certificate (not secure) | Skips verification of the server TLS certificate. | No |
| Use system proxy settings | Routes requests through the system proxy. | No |
| Fetch events | Enables continuous event collection into Cortex XSIAM. | No |
| Event Types | Cisco ETD log types to collect (message, audit, connection). If none are selected, all types are collected. |
No |
| Max fetch | Maximum number of events to collect per fetch cycle. Any remainder is collected on the following cycle. Default is 5000. |
No |
| Events Fetch Interval | Interval, in minutes, between fetch cycles. Default is 60, matching the hourly publication of ETD export files. |
No |
Regional Base URLs
| Region | Base URL |
|---|---|
| Americas | https://api.us.etd.cisco.com |
| Europe | https://api.de.etd.cisco.com |
| Australia | https://api.au.etd.cisco.com |
| India | https://api.in.etd.cisco.com |
| UAE | https://api.ae.etd.cisco.com |
Commands
cisco-etd-get-events
Retrieves Cisco ETD logs for an explicit time range.
Warning: Use this command for development and debugging only, as it may produce duplicate events, exceed API rate limits, or disrupt the fetch mechanism.
Base Command
cisco-etd-get-events
Input
| Argument Name | Description | Required |
|---|---|---|
| start_time | Start of the time range. Accepts a date, a timestamp, or a relative expression such as 3 hours ago. Rounded down to the start of the hour. Default is 1 hour ago. |
No |
| end_time | End of the time range. Accepts a date, a timestamp, or a relative expression such as now. Rounded down to the start of the hour. The hour currently in progress cannot be retrieved. Default is now. |
No |
| log_type | One or more log types to retrieve (message, audit, connection). If not provided, all types are retrieved. |
No |
| limit | Maximum number of events to return. Default is 100. |
No |
| should_push_events | If true, sends the retrieved events to Cortex XSIAM. Default is false. |
No |
Context Output
There is no context output for this command. Retrieved events are displayed in the war room as a summary table, and are only ingested when should_push_events is set to true.
Troubleshooting
| Symptom | Cause and resolution |
|---|---|
| Test succeeds but no events are collected | Log export is likely not enabled in the ETD UI. Enable the relevant log types under Administration > Business > Export Log Preferences and wait 15 to 20 minutes for the first export files to be generated. |
Cisco ETD denied the request. Verify the API Key... |
The x-api-key header was rejected. Confirm the API Key is correct and belongs to the same tenant as the Client ID and Client Secret. |
The Cisco ETD access token expired and could not be refreshed... |
The Client ID or Client Secret is invalid or was rotated. Generate new API credentials in the ETD UI and update the instance. |
The Cisco ETD API rate limit or daily quota was exceeded... |
The tenant exceeded its API quota. Increase the Events Fetch Interval, avoid running cisco-etd-get-events repeatedly, and contact Cisco support to request a quota increase. |
Cisco ETD rejected the request. Verify the configured time range... |
The requested range extended into the current hour, exceeded 3 hours, or was older than the 30 day retention period. |
The Cisco ETD API is temporarily unavailable... |
A transient service error. The affected hours are retried automatically on the next fetch cycle. |
| Warning about truncated download links in the logs | Cisco ETD returns at most 200 download links per request and discards the rest, meaning some events for that hour cannot be collected. Contact Cisco support if the tenant consistently exceeds this volume. |
| Events appear with a delay | Expected. Export files are published hourly and continue to be generated for up to 20 minutes after an hour ends. |
| Events are missing after an outage | Use cisco-etd-get-events with should_push_events=true to recover a specific time range, as long as it falls within the 30 day retention period. |
Configuration parameters
etd_base_url— ETD API Base URL (required)api_key— (required)client_id— Client ID (required)client_secret— (required)insecure— Trust any certificate (not secure)proxy— Use system proxy settingsisFetchEvents— Fetch eventsevent_type— Event Typesmax_fetch— Max fetcheventFetchInterval— Events Fetch Interval
Commands (1)
-
cisco-etd-get-eventsRetrieves ETD logs from Cisco. Use this command for development and debugging only, as it may produce duplicate events, exceed API rate limits, or disrupt the fetch mechanism.
import json from datetime import UTC, datetime, timedelta from unittest.mock import MagicMock import pytest from CommonServerPython import DemistoException from CiscoETDConnector import ( ETDClient, FetchState, calculate_fetch_window, cisco_etd_get_events_command, deduplicate_events, fetch_events, format_event_time, generate_intervals, get_credential, get_event_id, get_event_time, get_object_path, is_fatal_error, parse_command_range, parse_timestamp, ) # Imported under an alias so pytest does not collect the integration's test-module command # as if it were a test case. from CiscoETDConnector import test_module as run_test_module WINDOW_START = datetime(2026, 7, 1, 10, 0, tzinfo=UTC) LINK = "https://bucket.s3.amazonaws.com/tenant_id%3Dt1/log_date%3D2026-07-01/hour%3D10/log_type%3Dmessage/0000.jsonl?X-Amz-Signature=abc" LINK_PATH = "/tenant_id%3Dt1/log_date%3D2026-07-01/hour%3D10/log_type%3Dmessage/0000.jsonl" def recent_link(hours_ago: int = 1, part: str = "0000") -> tuple[str, str]: """Build a link inside the live fetch window, so state is not pruned as out of retention.""" moment = datetime.now(UTC) - timedelta(hours=hours_ago) path = ( f"/tenant_id%3Dt1/log_date%3D{moment.strftime('%Y-%m-%d')}" f"/hour%3D{moment.strftime('%H')}/log_type%3Dmessage/{part}.jsonl" ) return f"https://bucket.s3.amazonaws.com{path}?X-Amz-Signature=abc", path def build_client() -> ETDClient: """Create a client without running the network-dependent constructor.""" client = ETDClient.__new__(ETDClient) client.params = {} client.api_key = "api-key" client._headers = {} client._verify = True client._download_session = MagicMock() return client def mock_download(client: ETDClient, lines: list[str], status_code: int = 200) -> MagicMock: response = MagicMock() response.status_code = status_code response.text = "error body" response.iter_lines.return_value = iter(lines) response.__enter__ = MagicMock(return_value=response) response.__exit__ = MagicMock(return_value=False) client._download_session.get.return_value = response return response """ CREDENTIALS """ @pytest.mark.parametrize( "param, expected", [ ("secret", "secret"), ({"password": "secret"}, "secret"), ({"credentials": {"password": "secret"}}, "secret"), (None, ""), ({}, ""), ("", ""), ], ) def test_get_credential(param, expected): """Every credential shape resolves to a string, never to None, so auth tuples stay valid.""" assert get_credential(param) == expected """ TIME HANDLING """ def test_parse_timestamp_converts_offset_to_utc(): """Regression: a non-UTC offset must be converted, not relabelled as Z.""" assert parse_timestamp("2026-07-01T10:00:00+03:00") == datetime(2026, 7, 1, 7, 0, tzinfo=UTC) def test_parse_timestamp_treats_naive_as_utc(): """ETD audit logs emit naive timestamps that are documented as UTC.""" assert parse_timestamp("2026-06-16 06:54:55") == datetime(2026, 6, 16, 6, 54, 55, tzinfo=UTC) @pytest.mark.parametrize("value", ["", " ", "not-a-date", None, 12345, {}]) def test_parse_timestamp_invalid(value): assert parse_timestamp(value) is None def test_format_event_time_keeps_milliseconds(): """Sub-second precision is retained so events in the same second stay distinguishable.""" assert format_event_time(datetime(2026, 7, 1, 10, 0, 0, 123456, tzinfo=UTC)) == "2026-07-01T10:00:00.123Z" def test_get_event_time_offset_is_converted(): """Regression: the full event path must normalize an offset timestamp to real UTC.""" event = {"message": {"timestamp": "2026-07-01T13:00:00+03:00"}} assert get_event_time(event, "message", WINDOW_START) == "2026-07-01T10:00:00.000Z" def test_get_event_time_falls_back_to_action_then_verdict(): """Update events carry no top-level timestamp, only an action or verdict timestamp.""" action_event = {"message": {"action": {"timestamp": "2026-07-01T12:36:50.764Z"}}} assert get_event_time(action_event, "message", WINDOW_START).startswith("2026-07-01T12:36:50") verdict_event = {"message": {"verdict": {"timestamp": "2026-07-01T12:36:28.412Z"}}} assert get_event_time(verdict_event, "message", WINDOW_START).startswith("2026-07-01T12:36:28") def test_get_event_time_falls_back_to_partition(): """When no timestamp exists, the logDate/logHour partition gives a real, bounded time.""" event = {"message": {}, "logDate": "2026-07-01", "logHour": "06"} assert get_event_time(event, "message", WINDOW_START) == "2026-07-01T06:00:00.000Z" def test_get_event_time_falls_back_to_window_start_not_now(): """Regression: an untimed event must not be stamped 'now', which would skew the checkpoint.""" assert get_event_time({"message": {}}, "message", WINDOW_START) == "2026-07-01T10:00:00.000Z" def test_get_event_time_handles_non_dict_message(): """Regression: a malformed message field must not raise and abort the whole file.""" assert get_event_time({"message": "unexpected string"}, "message", WINDOW_START) == "2026-07-01T10:00:00.000Z" def test_get_event_time_handles_non_dict_action(): assert get_event_time({"message": {"action": ["unexpected"]}}, "message", WINDOW_START) == "2026-07-01T10:00:00.000Z" """ EVENT IDENTITY """ def test_get_event_id_message_is_stable_for_unrelated_changes(): """Regression: re-exporting the same message state must not create a new id.""" first = {"message": {"id": "abc", "eventType": "create", "subject": "hello"}, "logHour": "10"} second = {"message": {"id": "abc", "eventType": "create", "subject": "hello", "urls": [{"url": "x"}]}, "logHour": "11"} assert get_event_id(first, "message") == get_event_id(second, "message") def test_get_event_id_message_changes_on_new_verdict(): """A reclassification is a genuinely new event and must produce a distinct id.""" created = {"message": {"id": "abc", "eventType": "create"}} updated = {"message": {"id": "abc", "eventType": "update", "verdict": {"timestamp": "2026-07-01T12:00:00Z"}}} assert get_event_id(created, "message") != get_event_id(updated, "message") def test_get_event_id_message_changes_on_new_action(): base = {"message": {"id": "abc", "eventType": "update"}} remediated = {"message": {"id": "abc", "eventType": "update", "action": {"timestamp": "2026-07-01T12:36:50Z"}}} assert get_event_id(base, "message") != get_event_id(remediated, "message") def test_get_event_id_message_without_id_hashes_body(): """Without the ETD id there is nothing stable to key on, so the body is hashed.""" assert len(get_event_id({"message": {"subject": "x"}}, "message")) == 64 def test_get_event_id_distinct_messages_differ(): first = {"message": {"id": "abc", "eventType": "create"}} second = {"message": {"id": "def", "eventType": "create"}} assert get_event_id(first, "message") != get_event_id(second, "message") def test_get_event_id_connection_distinguishes_records(): """Regression: two records sharing a connection id must not collapse into one event.""" first = {"connection_id": "c1", "timestamp": "2026-07-01T10:00:00Z", "reason": "blocked"} second = {"connection_id": "c1", "timestamp": "2026-07-01T10:05:00Z", "reason": "allowed"} assert get_event_id(first, "connection") != get_event_id(second, "connection") def test_get_event_id_audit_is_deterministic(): event = {"category": "user", "timestamp": "2026-06-16 06:54:55", "action": "login", "user": {"id": "u1"}} assert get_event_id(event, "audit") == get_event_id(dict(reversed(list(event.items()))), "audit") """ DEDUPLICATION """ def test_deduplicate_events_removes_repeats_and_tracks_ids(): seen: set[str] = set() events = [{"event_id": "1"}, {"event_id": "1"}, {"event_id": "2"}] assert deduplicate_events(events, seen) == [{"event_id": "1"}, {"event_id": "2"}] assert seen == {"1", "2"} def test_deduplicate_events_across_calls(): """Ids carry across files within a cycle, since one event can appear in two export files.""" seen: set[str] = set() deduplicate_events([{"event_id": "1"}], seen) assert deduplicate_events([{"event_id": "1"}, {"event_id": "2"}], seen) == [{"event_id": "2"}] """ INTERVALS AND WINDOW """ def test_generate_intervals_splits_and_covers_range(): start, end = datetime(2026, 7, 1, 0, tzinfo=UTC), datetime(2026, 7, 1, 5, tzinfo=UTC) intervals = generate_intervals(start, end, 3) assert intervals == [(start, datetime(2026, 7, 1, 3, tzinfo=UTC)), (datetime(2026, 7, 1, 3, tzinfo=UTC), end)] def test_generate_intervals_empty_when_start_not_before_end(): """Regression: an inverted range yields no work instead of looping or raising.""" start = datetime(2026, 7, 1, 5, tzinfo=UTC) assert generate_intervals(start, start, 1) == [] assert generate_intervals(start, start - timedelta(hours=2), 1) == [] def test_generate_intervals_rejects_oversized_window(): """The API rejects ranges longer than 3 hours, so the error is raised before the call.""" start, end = datetime(2026, 7, 1, 0, tzinfo=UTC), datetime(2026, 7, 1, 9, tzinfo=UTC) with pytest.raises(DemistoException, match="exceeds the API maximum"): generate_intervals(start, end, 4) def test_calculate_fetch_window_first_run_is_recent_only(): """First run collects the last completed hour, never historical data.""" now = datetime(2026, 7, 1, 10, 42, tzinfo=UTC) start, end = calculate_fetch_window(None, now) assert (start, end) == (datetime(2026, 7, 1, 9, tzinfo=UTC), datetime(2026, 7, 1, 10, tzinfo=UTC)) def test_calculate_fetch_window_never_requests_current_hour(): """Regression: the API rejects a range extending into the in-progress hour.""" now = datetime(2026, 7, 1, 10, 59, tzinfo=UTC) _, end = calculate_fetch_window("2026-07-01T05", now) assert end == datetime(2026, 7, 1, 10, tzinfo=UTC) def test_calculate_fetch_window_applies_lookback(): """Recent hours are re-read because ETD keeps writing export files after an hour closes.""" now = datetime(2026, 7, 1, 10, 5, tzinfo=UTC) start, _ = calculate_fetch_window("2026-07-01T08", now) assert start == datetime(2026, 7, 1, 6, tzinfo=UTC) def test_calculate_fetch_window_clamps_to_retention(): """The API rejects timestamps older than 30 days, so the start is moved forward.""" now = datetime(2026, 7, 1, 10, 0, tzinfo=UTC) start, end = calculate_fetch_window("2026-01-01T00", now) assert start == end - timedelta(days=30) def test_calculate_fetch_window_handles_future_checkpoint(): """A checkpoint ahead of now must not produce an inverted window that stalls the fetch.""" now = datetime(2026, 7, 1, 10, 0, tzinfo=UTC) start, end = calculate_fetch_window("2026-07-05T00", now) assert start < end """ FETCH STATE """ def test_get_object_path_ignores_signature(): """The signature changes every request, so only the object path identifies the file.""" assert get_object_path(LINK) == LINK_PATH assert get_object_path(LINK.replace("abc", "zzz")) == LINK_PATH def test_fetch_state_tracks_and_prunes_files(): state = FetchState({}) state.mark_complete("/log_date%3D2026-07-01/hour%3D10/a.jsonl") state.mark_complete("/log_date%3D2026-06-01/hour%3D10/old.jsonl") last_run = state.to_last_run("2026-07-01T11") assert "/log_date%3D2026-07-01/hour%3D10/a.jsonl" in last_run["processed_files"] assert "/log_date%3D2026-06-01/hour%3D10/old.jsonl" not in last_run["processed_files"] def test_fetch_state_resume_offset_roundtrip(): state = FetchState({"partial_file": {"path": LINK_PATH, "offset": 120}}) assert state.resume_offset(LINK_PATH) == 120 assert state.resume_offset("/other") == 0 state.mark_complete(LINK_PATH) assert state.resume_offset(LINK_PATH) == 0 """ DOWNLOAD """ def test_stream_events_parses_and_enriches(): client = build_client() mock_download(client, ['{"message":{"id":"1","timestamp":"2026-07-01T10:00:00Z"}}']) events, offset, completed = client.stream_events("message", LINK, WINDOW_START, 100) assert len(events) == 1 assert events[0]["source_log_type"] == "message" assert events[0]["_time"] == "2026-07-01T10:00:00.000Z" assert events[0]["event_id"] assert (offset, completed) == (1, True) def test_stream_events_skips_blank_and_malformed_lines(mocker): """A bad line must not discard the good lines that follow it in the same file.""" error = mocker.patch("CiscoETDConnector.demisto.error") client = build_client() mock_download(client, ['{"message":{"id":"1"}}', "", " ", "not json", "[1,2]", '{"message":{"id":"2"}}']) events, _, completed = client.stream_events("message", LINK, WINDOW_START, 100) assert [event["message"]["id"] for event in events] == ["1", "2"] assert completed is True # Both the unparsable line and the non-object line are reported. assert error.call_count == 2 def test_stream_events_stops_at_limit_and_reports_offset(): """Hitting the limit mid-file reports the resume point instead of dropping the remainder.""" client = build_client() mock_download(client, ['{"message":{"id":"1"}}', '{"message":{"id":"2"}}', '{"message":{"id":"3"}}']) events, offset, completed = client.stream_events("message", LINK, WINDOW_START, 2) assert len(events) == 2 assert (offset, completed) == (2, False) def test_stream_events_resumes_from_offset(): client = build_client() mock_download(client, ['{"message":{"id":"1"}}', '{"message":{"id":"2"}}']) events, _, _ = client.stream_events("message", LINK, WINDOW_START, 100, start_offset=1) assert [event["message"]["id"] for event in events] == ["2"] def test_stream_events_uses_streaming(): """Regression: the file must be streamed so a large export is not buffered in memory.""" client = build_client() mock_download(client, ['{"message":{"id":"1"}}']) client.stream_events("message", LINK, WINDOW_START, 100) assert client._download_session.get.call_args.kwargs["stream"] is True def test_stream_events_raises_on_http_error(): client = build_client() mock_download(client, [], status_code=500) with pytest.raises(DemistoException, match="Failed downloading"): client.stream_events("message", LINK, WINDOW_START, 100) """ LINKS """ def test_get_links_collects_per_type(): client = build_client() response = {"data": {"message": ["a"], "audit": ["b"], "connection": ["c"]}} assert client.get_links(response, ["message", "audit", "connection"]) == [ ("message", "a"), ("audit", "b"), ("connection", "c"), ] @pytest.mark.parametrize("payload", [{"data": {}}, {}, {"data": {"message": "oops", "audit": None, "connection": {}}}]) def test_get_links_tolerates_malformed_payloads(payload): client = build_client() assert client.get_links(payload, ["message", "audit", "connection"]) == [] def test_get_links_warns_when_api_truncates(mocker): """The API caps the response at 200 links and silently drops the rest, so it is surfaced.""" error = mocker.patch("CiscoETDConnector.demisto.error") client = build_client() client.get_links({"data": {"message": [f"link{i}" for i in range(200)]}}, ["message"]) assert "truncated" in error.call_args[0][0] """ AUTHENTICATION """ def test_get_access_token_uses_cache(mocker): mocker.patch( "CiscoETDConnector.demisto.getIntegrationContext", return_value={"access_token": "cached", "token_expiry": (datetime.now(UTC) + timedelta(hours=1)).timestamp()}, ) client = build_client() request = mocker.patch.object(ETDClient, "_http_request") assert client.get_access_token() == "cached" request.assert_not_called() def test_get_access_token_refreshes_near_expiry(mocker): """A token about to expire is replaced up front rather than failing mid-fetch.""" mocker.patch( "CiscoETDConnector.demisto.getIntegrationContext", return_value={"access_token": "stale", "token_expiry": (datetime.now(UTC) + timedelta(seconds=30)).timestamp()}, ) mocker.patch("CiscoETDConnector.demisto.setIntegrationContext") client = build_client() mocker.patch.object(ETDClient, "_http_request", return_value={"accessToken": "fresh", "expiresIn": 3600}) assert client.get_access_token() == "fresh" def test_get_access_token_honours_expires_in(mocker): """The TTL comes from the API response rather than a hardcoded assumption.""" mocker.patch("CiscoETDConnector.demisto.getIntegrationContext", return_value={}) set_context = mocker.patch("CiscoETDConnector.demisto.setIntegrationContext") client = build_client() mocker.patch.object(ETDClient, "_http_request", return_value={"accessToken": "t", "expiresIn": 120}) client.get_access_token() saved = set_context.call_args[0][0] assert saved["token_expiry"] - datetime.now(UTC).timestamp() == pytest.approx(120, abs=5) def test_get_access_token_preserves_existing_context(mocker): """The context is merged, not overwritten, so unrelated keys survive.""" mocker.patch("CiscoETDConnector.demisto.getIntegrationContext", return_value={"unrelated": "keep"}) set_context = mocker.patch("CiscoETDConnector.demisto.setIntegrationContext") client = build_client() mocker.patch.object(ETDClient, "_http_request", return_value={"accessToken": "t"}) client.get_access_token() assert set_context.call_args[0][0]["unrelated"] == "keep" def test_get_access_token_missing_token(mocker): mocker.patch("CiscoETDConnector.demisto.getIntegrationContext", return_value={}) client = build_client() mocker.patch.object(ETDClient, "_http_request", return_value={}) with pytest.raises(DemistoException, match="no access token"): client.get_access_token() def test_request_log_export_retries_once_on_401(mocker): """A token expiring mid-fetch is refreshed and the request retried, not failed.""" client = build_client() expired = DemistoException("expired", res=MagicMock(status_code=401)) request = mocker.patch.object(ETDClient, "_http_request", side_effect=[expired, {"data": {}}]) authenticate = mocker.patch.object(ETDClient, "_authenticate") assert client.request_log_export("2026-07-01T10", "2026-07-01T11", ["message"]) == {"data": {}} authenticate.assert_called_once_with(force_refresh=True) assert request.call_count == 2 @pytest.mark.parametrize( "status_code, expected", [ (403, "Verify the API Key"), (429, "rate limit"), (503, "temporarily unavailable"), ], ) def test_request_log_export_translates_errors(mocker, status_code, expected): """API errors are surfaced with the action the user needs to take.""" client = build_client() mocker.patch.object(ETDClient, "_http_request", side_effect=DemistoException("x", res=MagicMock(status_code=status_code))) with pytest.raises(DemistoException, match=expected): client.request_log_export("2026-07-01T10", "2026-07-01T11", ["message"]) @pytest.mark.parametrize("status_code, fatal", [(401, True), (403, True), (400, True), (429, False), (503, False), (None, False)]) def test_is_fatal_error(status_code, fatal): assert is_fatal_error(DemistoException("x", res=MagicMock(status_code=status_code))) is fatal """ FETCH """ @pytest.fixture def fetch_mocks(mocker): mocker.patch("CiscoETDConnector.demisto.getLastRun", return_value={}) return { "set_last_run": mocker.patch("CiscoETDConnector.demisto.setLastRun"), "send": mocker.patch("CiscoETDConnector.send_events_to_xsiam"), } def test_fetch_events_sends_with_vendor_and_product(mocker, fetch_mocks): client = build_client() mocker.patch.object(ETDClient, "request_log_export", return_value={"data": {"message": [LINK]}}) mocker.patch.object( ETDClient, "stream_events", return_value=([{"event_id": "1", "_time": "2026-07-01T10:00:00.000Z"}], 1, True), ) fetch_events(client, {"max_fetch": 100, "event_type": ["message"]}) kwargs = fetch_mocks["send"].call_args.kwargs assert kwargs["vendor"] == "Cisco" assert kwargs["product"] == "ETD" assert len(kwargs["events"]) == 1 def test_fetch_events_saves_checkpoint_with_processed_files(mocker, fetch_mocks): """The checkpoint records the hour reached and the files already ingested.""" link, path = recent_link() client = build_client() mocker.patch.object(ETDClient, "request_log_export", return_value={"data": {"message": [link]}}) mocker.patch.object(ETDClient, "stream_events", return_value=([{"event_id": "1", "_time": "t"}], 1, True)) fetch_events(client, {"max_fetch": 100, "event_type": ["message"]}) last_run = fetch_mocks["set_last_run"].call_args[0][0] assert last_run["last_hour"] assert path in last_run["processed_files"] def test_fetch_state_prunes_files_outside_the_lookback_window(): """Files older than the next lookback window are dropped so the state cannot grow forever.""" state = FetchState({}) _, recent_path = recent_link() state.mark_complete(recent_path) state.mark_complete(LINK_PATH) # dated 2026-07-01, far outside the window retained = state.to_last_run(datetime.now(UTC).strftime("%Y-%m-%dT%H"))["processed_files"] assert recent_path in retained assert LINK_PATH not in retained def test_fetch_events_skips_already_processed_files(mocker): """Regression: re-reading an hour must not re-ingest files from the previous cycle.""" now_hour = datetime.now(UTC).replace(minute=0, second=0, microsecond=0) mocker.patch( "CiscoETDConnector.demisto.getLastRun", return_value={"last_hour": (now_hour - timedelta(hours=1)).strftime("%Y-%m-%dT%H"), "processed_files": [LINK_PATH]}, ) mocker.patch("CiscoETDConnector.demisto.setLastRun") send = mocker.patch("CiscoETDConnector.send_events_to_xsiam") client = build_client() mocker.patch.object(ETDClient, "request_log_export", return_value={"data": {"message": [LINK]}}) stream = mocker.patch.object(ETDClient, "stream_events") fetch_events(client, {"max_fetch": 100, "event_type": ["message"]}) stream.assert_not_called() send.assert_not_called() def test_fetch_events_no_events_still_advances_checkpoint(mocker, fetch_mocks): """An empty hour is a completed hour, so the window must move forward.""" client = build_client() mocker.patch.object(ETDClient, "request_log_export", return_value={"data": {"message": []}}) fetch_events(client, {"max_fetch": 100, "event_type": ["message"]}) fetch_mocks["send"].assert_not_called() fetch_mocks["set_last_run"].assert_called_once() def test_fetch_events_truncates_at_max_fetch_and_resumes(mocker, fetch_mocks): """Regression: the remainder of a truncated file is recorded so nothing is lost.""" client = build_client() mocker.patch.object(ETDClient, "request_log_export", return_value={"data": {"message": [LINK]}}) mocker.patch.object( ETDClient, "stream_events", return_value=([{"event_id": "1", "_time": "t"}, {"event_id": "2", "_time": "t"}], 2, False), ) fetch_events(client, {"max_fetch": 2, "event_type": ["message"]}) last_run = fetch_mocks["set_last_run"].call_args[0][0] assert last_run["partial_file"] == {"path": LINK_PATH, "offset": 2} assert LINK_PATH not in last_run["processed_files"] def test_fetch_events_deduplicates_across_files(mocker, fetch_mocks): """The same event appearing in two export files of one hour is sent only once.""" client = build_client() second_link = LINK.replace("0000.jsonl", "0001.jsonl") mocker.patch.object(ETDClient, "request_log_export", return_value={"data": {"message": [LINK, second_link]}}) mocker.patch.object(ETDClient, "stream_events", return_value=([{"event_id": "dup", "_time": "t"}], 1, True)) fetch_events(client, {"max_fetch": 100, "event_type": ["message"]}) total_sent = sum(len(call.kwargs["events"]) for call in fetch_mocks["send"].call_args_list) assert total_sent == 1 def test_fetch_events_raises_and_preserves_window_on_error(mocker, fetch_mocks): """Regression: a failure must surface and must not advance past the failed hour.""" error = mocker.patch("CiscoETDConnector.demisto.error") client = build_client() mocker.patch.object(ETDClient, "request_log_export", side_effect=DemistoException("denied", res=MagicMock(status_code=403))) with pytest.raises(DemistoException, match="denied"): fetch_events(client, {"max_fetch": 100, "event_type": ["message"]}) last_run = fetch_mocks["set_last_run"].call_args[0][0] assert last_run["processed_files"] == [] error.assert_called_once() def test_fetch_events_partial_progress_is_kept_on_error(mocker): """Files ingested before a mid-cycle failure are not downloaded again on the retry.""" now_hour = datetime.now(UTC).replace(minute=0, second=0, microsecond=0) link, path = recent_link(hours_ago=3) mocker.patch("CiscoETDConnector.demisto.error") mocker.patch( "CiscoETDConnector.demisto.getLastRun", return_value={"last_hour": (now_hour - timedelta(hours=3)).strftime("%Y-%m-%dT%H")}, ) set_last_run = mocker.patch("CiscoETDConnector.demisto.setLastRun") mocker.patch("CiscoETDConnector.send_events_to_xsiam") client = build_client() mocker.patch.object( ETDClient, "request_log_export", side_effect=[{"data": {"message": [link]}}, DemistoException("boom", res=MagicMock(status_code=503))], ) mocker.patch.object(ETDClient, "stream_events", return_value=([{"event_id": "1", "_time": "t"}], 1, True)) with pytest.raises(DemistoException): fetch_events(client, {"max_fetch": 100, "event_type": ["message"]}) assert path in set_last_run.call_args[0][0]["processed_files"] def test_fetch_events_stops_on_time_budget(mocker, fetch_mocks): """The fetch yields before the container timeout so the checkpoint can be saved.""" client = build_client() mocker.patch("CiscoETDConnector.time.time", side_effect=[0] + [10**9] * 20) request = mocker.patch.object(ETDClient, "request_log_export", return_value={"data": {"message": []}}) fetch_events(client, {"max_fetch": 100, "event_type": ["message"]}) request.assert_not_called() fetch_mocks["set_last_run"].assert_called_once() def test_fetch_events_defaults_to_all_log_types(mocker, fetch_mocks): """An unset Event Types parameter collects every type rather than failing.""" client = build_client() request = mocker.patch.object(ETDClient, "request_log_export", return_value={"data": {}}) fetch_events(client, {}) assert request.call_args[0][2] == ["message", "audit", "connection"] @pytest.mark.parametrize("max_fetch", ["", None, "abc"]) def test_fetch_events_invalid_max_fetch_uses_default(mocker, fetch_mocks, max_fetch): """Regression: a blank or non-numeric max_fetch must not crash the fetch.""" client = build_client() mocker.patch.object(ETDClient, "request_log_export", return_value={"data": {}}) fetch_events(client, {"max_fetch": max_fetch, "event_type": ["message"]}) fetch_mocks["set_last_run"].assert_called_once() """ GET EVENTS COMMAND """ def test_parse_command_range_accepts_relative_time(mocker): """Regression: time arguments are parsed with dateparser, not a single rigid format.""" mocker.patch("CiscoETDConnector.datetime", wraps=datetime) start, end = parse_command_range({"start_time": "2026-07-01T09:30:00Z", "end_time": "2026-07-01T11:45:00Z"}) assert (start, end) == (datetime(2026, 7, 1, 9, tzinfo=UTC), datetime(2026, 7, 1, 11, tzinfo=UTC)) def test_parse_command_range_rejects_inverted_range(): with pytest.raises(DemistoException, match="at least one full hour earlier"): parse_command_range({"start_time": "2026-07-01T11:00:00Z", "end_time": "2026-07-01T10:00:00Z"}) def test_parse_command_range_rejects_beyond_retention(): with pytest.raises(DemistoException, match="30 days"): parse_command_range({"start_time": "40 days ago", "end_time": "now"}) def test_get_events_command_does_not_push_by_default(mocker): client = build_client() send = mocker.patch("CiscoETDConnector.send_events_to_xsiam") mocker.patch.object(ETDClient, "request_log_export", return_value={"data": {"message": [LINK]}}) mocker.patch.object(ETDClient, "stream_events", return_value=([{"event_id": "1", "_time": "t"}], 1, True)) results = cisco_etd_get_events_command(client, {"start_time": "3 hours ago", "end_time": "1 hour ago"}) send.assert_not_called() assert "Cisco ETD events" in results.readable_output def test_get_events_command_pushes_when_requested(mocker): client = build_client() send = mocker.patch("CiscoETDConnector.send_events_to_xsiam") mocker.patch.object(ETDClient, "request_log_export", return_value={"data": {"message": [LINK]}}) mocker.patch.object(ETDClient, "stream_events", return_value=([{"event_id": "1", "_time": "t"}], 1, True)) cisco_etd_get_events_command(client, {"start_time": "3 hours ago", "end_time": "1 hour ago", "should_push_events": "true"}) send.assert_called_once() def test_get_events_command_does_not_touch_last_run(mocker): """The debug command must never move the fetch checkpoint.""" client = build_client() set_last_run = mocker.patch("CiscoETDConnector.demisto.setLastRun") mocker.patch("CiscoETDConnector.send_events_to_xsiam") mocker.patch.object(ETDClient, "request_log_export", return_value={"data": {"message": [LINK]}}) mocker.patch.object(ETDClient, "stream_events", return_value=([{"event_id": "1", "_time": "t"}], 1, True)) cisco_etd_get_events_command(client, {"start_time": "3 hours ago", "end_time": "1 hour ago"}) set_last_run.assert_not_called() def test_get_events_command_respects_limit(mocker): client = build_client() mocker.patch.object(ETDClient, "request_log_export", return_value={"data": {"message": [LINK]}}) stream = mocker.patch.object( ETDClient, "stream_events", return_value=([{"event_id": str(i), "_time": "t"} for i in range(5)], 5, False) ) cisco_etd_get_events_command(client, {"start_time": "3 hours ago", "end_time": "1 hour ago", "limit": "5"}) assert stream.call_args[0][3] == 5 def test_get_events_command_empty_result(mocker): client = build_client() mocker.patch.object(ETDClient, "request_log_export", return_value={"data": {}}) results = cisco_etd_get_events_command(client, {"start_time": "3 hours ago", "end_time": "1 hour ago"}) assert "No events were found" in results.readable_output """ TEST MODULE """ def test_test_module_does_not_download(mocker): """Connectivity is verified without downloading export files, so Test stays fast.""" client = build_client() request = mocker.patch.object(ETDClient, "request_log_export", return_value={"data": {}}) stream = mocker.patch.object(ETDClient, "stream_events") assert run_test_module(client) == "ok" request.assert_called_once() stream.assert_not_called() def test_test_module_propagates_auth_error(mocker): client = build_client() mocker.patch.object( ETDClient, "request_log_export", side_effect=DemistoException("Verify the API Key", res=MagicMock(status_code=403)) ) with pytest.raises(DemistoException, match="Verify the API Key"): run_test_module(client) """ MAIN """ def test_main_reports_the_real_error(mocker): """Regression: failures must not all be reported as an authentication problem.""" mocker.patch("CiscoETDConnector.demisto.error") mocker.patch("CiscoETDConnector.demisto.params", return_value={"etd_base_url": "https://api.us.etd.cisco.com"}) mocker.patch("CiscoETDConnector.demisto.command", return_value="fetch-events") mocker.patch.object(ETDClient, "__init__", return_value=None) mocker.patch("CiscoETDConnector.fetch_events", side_effect=DemistoException("rate limit exceeded")) return_error = mocker.patch("CiscoETDConnector.return_error") from CiscoETDConnector import main main() message = return_error.call_args[0][0] assert "rate limit exceeded" in message assert "Authentication failed" not in message def test_main_rejects_unknown_command(mocker): mocker.patch("CiscoETDConnector.demisto.error") mocker.patch("CiscoETDConnector.demisto.params", return_value={}) mocker.patch("CiscoETDConnector.demisto.command", return_value="cisco-etd-unknown") mocker.patch.object(ETDClient, "__init__", return_value=None) return_error = mocker.patch("CiscoETDConnector.return_error") from CiscoETDConnector import main main() assert "not implemented" in return_error.call_args[0][0] """ SAMPLE PAYLOADS FROM THE API DOCUMENTATION """ # Payloads copied from the Cisco Log Export API documentation. CREATE_SAMPLE = ( '{"message":{"eventType":"create","id":"0540d1c3","timestamp":"2025-07-16T05:59:42Z"},' '"tenantId":"07fa4225","logType":"message","logDate":"2025-07-16","logHour":"06"}' ) UPDATE_SAMPLE = ( '{"message":{"eventType":"update","id":"74572252","verdict":{"verdict":"phishing",' '"timestamp":"2025-07-21T12:36:28.412718327Z"}},"logDate":"2025-07-21","logHour":"12"}' ) AUDIT_SAMPLE = ( '{"category":"user","timestamp":"2025-06-16 06:54:55","action":"get_token","status":"success",' '"user":{"id":"0b74ee5b"},"metadata":null}' ) @pytest.mark.parametrize( "raw, log_type", [ (CREATE_SAMPLE, "message"), (UPDATE_SAMPLE, "message"), (AUDIT_SAMPLE, "audit"), ], ) def test_parse_line_against_documented_samples(raw, log_type): """Parsing is verified against the payloads published in the Cisco API documentation.""" event = ETDClient.parse_line(raw, log_type, WINDOW_START) assert event is not None assert event["source_log_type"] == log_type assert event["event_id"] assert event["_time"].endswith("Z") # The resolved time comes from the payload, not from the window fallback. assert event["_time"] != format_event_time(WINDOW_START) assert json.loads(raw).keys() <= event.keys()