VercaraUltraDNS

Vercara UltraDNS integration for Cortex. Leverage UltraDNS's cloud-based DNS event and configuration data for security automation and real-time threat detection in Cortex.

Analytics & SIEM · Vercara UltraDNS

Details

IDVercaraUltraDNS
ProviderDigiCert
CategoryAnalytics & SIEM
From Version8.4.0
Docker Imagedemisto/python3:3.12.13.10116658
Supported ModulesXSIAM

README

This integration fetches DNS configuration audit logs from Vercara UltraDNS platform.
This integration was integrated and tested with the 3.18.0 Vercara UltraDNS API.

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

Configure Vercara UltraDNS in Cortex

Parameter Description Required
Server URL The base URL for the Vercara UltraDNS API. Default is https://api.ultradns.com True
Username Username for authentication True
Password Password for authentication True
Trust any certificate (not secure) Use SSL secure connection or not. False
Use system proxy settings Use proxy settings for connection or not. False
Fetch events Whether to bring events or not. False
The maximum number of audit logs per fetch Maximum number of events to fetch per cycle. Default is 2,500, maximum is 2,500. False

Vercara UltraDNS Authentication

The integration uses OAuth 2.0 password grant flow for authentication:

  1. Initial Token Request: Uses username/password to obtain access token and refresh token
  2. Token Usage: Access token is used in Authorization header for API requests
  3. Token Refresh: Automatically refreshes tokens when they expire, with fallback to username/password if refresh fails.

For detailed API documentation, see the REST API User Guide.

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.

vercara-ultradns-get-events


Gets audit events from Vercara UltraDNS. Manual command to fetch and display events. This command is used for developing/debugging and is to be used with caution, as it can create events, leading to events duplication and exceeding the API request limitation.

Base Command

vercara-ultradns-get-events

Input

Argument Name Description Required
should_push_events If true, the command creates events; otherwise, it only displays them. Possible values are true and false. The default value is false. Required
limit Maximum number of events to return. Default is 50, maximum is 2,500. Optional
start_time Start time for event collection. Supports ISO format (“2023-01-01T00:00:00”) or natural language (“7 days ago”, “yesterday”, “1 week ago”). Required
end_time End time for event collection. Supports ISO format (“2023-01-01T23:59:59”) or natural language (“2 hours ago”, “now”). Default is now if not provided. Optional

Context Output

There is no context output for this command.

Configuration parameters

  • url — Server URL (required)
  • credentials — Username (required)
  • isFetchEvents — Fetch events
  • max_events_per_fetch — The maximum number of audit logs per fetch
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings

Commands (1)

  • vercara-ultradns-get-events

    Gets audit events from Vercara UltraDNS. Manual command to fetch and display events. This command is used for developing/debugging and is to be used with caution, as it can create events, leading to events duplication and exceeding the API request limitation.

import demistomock as demisto
from CommonServerPython import *
import urllib3
from typing import Any
from datetime import datetime, timedelta

urllib3.disable_warnings()


VENDOR = "vercara"
PRODUCT = "ultradns"
MAX_EVENTS_PER_FETCH = 2500
PAGINATION_LIMIT = 250
DEFAULT_GET_EVENTS_LIMIT = 50
TOKEN_ENDPOINT = "/authorization/token"
AUDIT_LOG_ENDPOINT = "/reports/dns_configuration/audit"
DATE_FORMAT = "%Y%m%d%H%M%S"
API_DATE_FORMAT = "%Y-%m-%d %H:%M:%S.%f"
MARGIN_TOKEN_EXPIRY_SECONDS = 60
MARGIN_FETCH_OVERLAP_SECONDS = 3
MARGIN_DEDUP_SAFETY_SECONDS = 1


class Client(BaseClient):
    """UltraDNS API client with OAuth authentication and audit log fetching."""

    def __init__(self, base_url: str, username: str, password: str, verify: bool = True, proxy: bool = False) -> None:
        """Initialize UltraDNS client with OAuth authentication support.

        Args:
            base_url: Base URL for UltraDNS API (e.g., https://api.ultradns.com)
            username: Username for OAuth authentication
            password: Password for OAuth authentication
            verify: Whether to verify SSL certificates (default: True)
            proxy: Whether to use proxy (default: False)
        """
        super().__init__(base_url=base_url, verify=verify, proxy=proxy)
        self.username = username
        self.password = password

        context = demisto.getIntegrationContext()
        self.access_token: str | None = context.get("access_token")
        self.refresh_token: str | None = context.get("refresh_token")
        expires_in = context.get("token_expires_in")
        self.token_expires_in: int | None = int(expires_in) if expires_in is not None else None
        self.token_obtained_time: datetime | None = None
        if context.get("token_obtained_time"):
            self.token_obtained_time = datetime.fromisoformat(context["token_obtained_time"])

    def get_access_token(self) -> str:
        """Get valid access token, refreshing if needed.

        Returns:
            str: Valid access token for API authentication
        """
        if self._is_token_valid():
            demisto.debug("Access token is valid, using existing token")
            return self.access_token  # type: ignore[return-value]

        if self.refresh_token:
            try:
                # Try refreshing the token first
                demisto.debug("Refreshing access token...")
                return self._request_access_token("refresh_token", refresh_token=self.refresh_token)
            except Exception as e:
                demisto.debug(f"Token refresh failed: {e}, obtaining new token")

        # Obtain a new token
        return self._request_access_token("password", username=self.username, password=self.password)

    def _is_token_valid(self) -> bool:
        """Check if current access token is valid.

        Returns:
            bool: True if token is valid and not expired, False otherwise
        """
        if not self.access_token or not self.token_expires_in or not self.token_obtained_time:
            demisto.debug("Token validation failed: missing token, expiration, or obtained time")
            return False

        token_expiry = self.token_obtained_time.timestamp() + self.token_expires_in - MARGIN_TOKEN_EXPIRY_SECONDS
        current_time = datetime.now().timestamp()
        is_valid = current_time < token_expiry

        return is_valid

    def _request_access_token(self, grant_type: str, **token_data) -> str:
        """Request new or refresh existing access token.

        Args:
            grant_type: Type of grant ("password" or "refresh_token")
            **token_data: Additional token data for request (e.g., username, password, or refresh_token)

        Returns:
            str: New access token
        """
        data = {"grant_type": grant_type, **token_data}
        response = self._http_request(
            method="POST", url_suffix=TOKEN_ENDPOINT, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}
        )

        self.access_token = response.get("accessToken")
        if "refreshToken" in response:
            self.refresh_token = response.get("refreshToken")
        self.token_expires_in = int(response.get("expiresIn", 3600))
        self.token_obtained_time = datetime.now()

        if not self.access_token:
            raise DemistoException("Failed to obtain access token")

        self._save_tokens_to_context()
        return self.access_token

    def _save_tokens_to_context(self) -> None:
        """Save OAuth tokens to integration context.

        Returns:
            None
        """
        context = {
            "access_token": self.access_token,
            "refresh_token": self.refresh_token,
            "token_expires_in": self.token_expires_in,
            "token_obtained_time": self.token_obtained_time.isoformat() if self.token_obtained_time else None,
        }
        demisto.setIntegrationContext(context)
        demisto.debug(f"Saved new token, expires in {self.token_expires_in}s")

    def get_audit_logs(
        self,
        start_time: datetime,
        end_time: datetime | None = None,
        limit: int = PAGINATION_LIMIT,
        cursor: str | None = None,
    ) -> dict[str, Any]:
        """Fetch audit logs from UltraDNS API.

        Note: API returns audit events in reverse chronological order (newest first).

        Args:
            start_time: Start time for audit log query (datetime object)
            end_time: End time for audit log query (defaults is now if None)
            limit: Maximum number of records to fetch (default: PAGINATION_LIMIT)
            cursor: Pagination cursor for next page (optional)

        Returns:
            dict[str, Any]: API response with auditRecords in reverse chronological order

        Raises:
            DemistoException: If JSON parsing fails
        """
        if not end_time:
            end_time = datetime.now()

        start_time_str = start_time.strftime(DATE_FORMAT)
        end_time_str = end_time.strftime(DATE_FORMAT)
        date_range = f"{start_time_str}-{end_time_str}"
        actual_limit = min(limit, PAGINATION_LIMIT)

        params = {"limit": actual_limit, "filter": f"date_range:{date_range}"}

        if cursor:
            params["cursor"] = cursor
            params["cursorOperation"] = "NEXT"

        headers = {"Authorization": f"Bearer {self.get_access_token()}", "Accept": "application/json"}

        cursor_info = f", cursor: {cursor[:20]}..." if cursor else "first page"
        demisto.debug(f"Requesting audit logs - Date range: {date_range}, limit: {actual_limit}, cursor info: {cursor_info}")

        response = self._http_request(
            method="GET", url_suffix=AUDIT_LOG_ENDPOINT, params=params, headers=headers, resp_type="response"
        )

        demisto.debug(f"API response status: {response.status_code}")
        demisto.debug(f"API response headers: {response.headers}")
        pagination_info = self._parse_pagination_headers(response.headers)

        try:
            json_response = response.json()
            events_count = len(json_response.get("auditRecords", []))
            demisto.debug(f"Received {events_count} events from API")
        except ValueError as e:
            demisto.error(f"Failed to parse API response as JSON: {e}")
            raise DemistoException(f"Failed to parse JSON response: {e}")

        json_response.update(pagination_info)
        return json_response

    def _parse_pagination_headers(self, headers: dict[str, str]) -> dict[str, Any]:
        """Parse pagination info from response headers.

        Args:
            headers: Response headers from API

        Returns:
            dict[str, Any]: Pagination info with next_cursor, limit, and results
        """
        import re

        pagination_info = {}

        link_header = headers.get("Link", "")
        if link_header and 'rel="next"' in link_header:
            cursor_match = re.search(r"cursor=([^&>]+)", link_header)
            if cursor_match:
                pagination_info["next_cursor"] = cursor_match.group(1)

        pagination_info["limit"] = headers.get("Limit")
        pagination_info["results"] = headers.get("Results")
        return pagination_info


def convert_time_string(time_str: str) -> datetime:
    """Convert API time string to datetime object.

    Args:
        time_str: Time string from API in format 'YYYY-MM-DD HH:MM:SS.f'

    Returns:
        datetime: Parsed datetime object

    Raises:
        DemistoException: If time string parsing fails
    """
    try:
        return datetime.strptime(time_str, API_DATE_FORMAT)
    except ValueError as e:
        raise DemistoException(f"Failed to parse time string '{time_str}' with expected format '{API_DATE_FORMAT}': {e}")


def _calculate_event_hash(event: dict[str, Any]) -> str:
    """Calculate hash of event for deduplication.

    Args:
        event: Event dictionary to hash

    Returns:
        str: SHA256 hash of the event content
    """
    import hashlib
    import json

    # Create a stable string representation of the event
    # Sort keys to ensure consistent hashing regardless of dict order
    event_str = json.dumps(event, sort_keys=True, default=str)
    return hashlib.sha256(event_str.encode()).hexdigest()


def _deduplicate_events(
    events: list[dict[str, Any]], event_cache: dict[str, str], upper_bound: datetime | None
) -> list[dict[str, Any]]:
    """Deduplicate events using hash-based comparison in boundary zone only.

    Args:
        events: List of processed events in reverse chronological order (newest first)
        event_cache: Cache of previous events {event_hash: timestamp_str}
        upper_bound: Only check duplicates for events <= this time (last_event_time + safety_margin)

    Returns:
        list[dict[str, Any]]: Deduplicated events (maintains original reverse order)
    """
    if not events:
        return []

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

    # Process events from oldest to newest (iterate backwards through array)
    for i, event in reversed(list(enumerate(events))):
        # Parse event timestamp
        event_time_str = event["changeTime"]
        event_datetime = convert_time_string(event_time_str)

        # If event is newer than upper boundary, add all remaining events and break
        if upper_bound and event_datetime > upper_bound:
            remaining_count = i + 1
            demisto.debug(
                f"Reached boundary at {event_time_str}, adding {remaining_count} remaining newer events without duplicate check"
            )
            filtered_events = events[: i + 1] + filtered_events
            break

        # Event is in boundary zone - check for duplicates
        event_hash = _calculate_event_hash(event)
        if event_hash in event_cache:
            demisto.debug(f"Duplicate detected - time: {event_time_str}, hash: {event_hash[:12]}..., dropping event {event}")
            continue

        # Event is unique
        filtered_events.insert(0, event)
    return filtered_events


def _cache_recent_events(events: list[dict[str, Any]], cache: dict[str, str], cutoff_time: datetime | None) -> None:
    """Cache recent events for future duplicate detection.

    Args:
        events: List of events in reverse chronological order (newest first)
        cache: Cache dictionary to update {event_hash: timestamp_str}
        cutoff_time: Only cache events newer or equal to this time
    """
    if not cutoff_time or not events:
        return

    for event in events:
        event_time_str = event["changeTime"]
        event_datetime = convert_time_string(event_time_str)

        if event_datetime < cutoff_time:
            break  # Events are newest first, so we can stop here

        event_hash = _calculate_event_hash(event)
        cache[event_hash] = event_datetime.strftime("%Y-%m-%dT%H:%M:%S")


def _cleanup_event_cache(event_cache: dict[str, str], cutoff_time: datetime) -> dict[str, str]:
    """Clean up old event cache entries that are outside the retention window.

    Args:
        event_cache: Dictionary with event hash as key and ISO timestamp string as value
        cutoff_time: Time cutoff for retention (overlap + safety margin before latest event)

    Returns:
        dict[str, str]: Cleaned cache with only recent entries
    """
    cleaned_cache = {}
    removed_count = 0
    cutoff_time_str = cutoff_time.strftime("%Y-%m-%dT%H:%M:%S")

    for event_hash, timestamp_str in event_cache.items():
        try:
            if timestamp_str >= cutoff_time_str:
                cleaned_cache[event_hash] = timestamp_str
            else:
                removed_count += 1
        except Exception:
            # Keep entries with invalid timestamps to avoid data loss
            cleaned_cache[event_hash] = timestamp_str

    if removed_count > 0:
        demisto.debug(f"Cleaned {removed_count} old entries from event cache")

    return cleaned_cache


def process_events_for_xsiam(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Process events for XSIAM ingestion - adds _time, _vendor, _product fields."""
    demisto.debug(f"Processing {len(events)} events for XSIAM ingestion")

    for event in events:
        datetime_obj = convert_time_string(event["changeTime"])
        event["_time"] = datetime_obj.timestamp()
        event["_vendor"] = VENDOR
        event["_product"] = PRODUCT
    return events


def test_module(client: Client, params: dict) -> str:
    """Test API connectivity and authentication.

    Args:
        client: UltraDNS client instance
        params: Integration parameters

    Returns:
        str: 'ok' if successful, error message if failed
    """
    demisto.debug("Starting test-module validation")

    # Validate max_events_per_fetch parameter in test-module
    configured_limit = arg_to_number(params.get("max_events_per_fetch")) or MAX_EVENTS_PER_FETCH
    if configured_limit > MAX_EVENTS_PER_FETCH:
        raise DemistoException(
            f"The maximum number of audit logs per fetch cannot exceed {MAX_EVENTS_PER_FETCH}. Configured: {configured_limit}"
        )
    try:
        demisto.debug("Testing OAuth authentication...")
        access_token = client.get_access_token()
        if not access_token:
            demisto.error("Test failed: Could not obtain access token")
            return "Authentication failed: Could not obtain access token"

        demisto.debug("Authentication successful, testing API connectivity...")
        end_time = datetime.now()
        start_time = end_time.replace(hour=0, minute=0, second=0, microsecond=0)
        response = client.get_audit_logs(start_time=start_time, end_time=end_time, limit=1)

        if "auditRecords" not in response:
            demisto.error("API test failed: Response missing auditRecords field")
            return "API connectivity test failed: Invalid response format"

        event_count = len(response.get("auditRecords", []))
        demisto.debug(f"API connectivity test successful, received {event_count} events")
        return "ok"

    except Exception as e:
        demisto.debug(f"Test failed: {e}")
        if "Forbidden" in str(e) or "Unauthorized" in str(e):
            return "Authentication Error: Please verify your username and password"
        elif "timeout" in str(e).lower():
            return "Connection timeout: Please verify the server URL"
        else:
            return f"Connection failed: {str(e)}"


def get_events_command(client: Client, args: dict[str, Any]) -> tuple[list[dict], CommandResults]:
    """Manual command to fetch UltraDNS audit events.

    Note: UltraDNS API returns events in reverse chronological order (newest first).

    Args:
        client: UltraDNS client instance
        args: Command arguments dictionary containing limit, start_time, end_time, should_push_events

    Returns:
        tuple[list[dict], CommandResults]: Events list (newest first) and CommandResults object

    Raises:
        DemistoException: If limit exceeds maximum or date parsing fails
    """
    demisto.debug(f"Executing get-events command with args: {args}")
    requested_limit = arg_to_number(args.get("limit")) or DEFAULT_GET_EVENTS_LIMIT
    if requested_limit > MAX_EVENTS_PER_FETCH:
        demisto.error(f"Requested limit {requested_limit} exceeds maximum {MAX_EVENTS_PER_FETCH}")
        raise DemistoException(f"Limit cannot exceed {MAX_EVENTS_PER_FETCH}. Requested: {requested_limit}")
    limit = requested_limit

    should_push_events = argToBoolean(args.get("should_push_events", False))

    start_time = dateparser.parse(args.get("start_time") or "")
    if not start_time:
        raise DemistoException(f"Invalid start_time format: {args.get('start_time')}")

    end_time_arg = args.get("end_time")
    if end_time_arg:
        end_time = dateparser.parse(end_time_arg)
        if not end_time:
            raise DemistoException(f"Invalid end_time format: {end_time_arg}")
    else:
        end_time = datetime.now()

    demisto.debug(f"Fetching events from {start_time} to {end_time}")

    all_events: list[dict[str, Any]] = []
    cursor = None
    page_count = 0

    while len(all_events) < limit:
        page_count += 1
        remaining_limit = min(limit - len(all_events), PAGINATION_LIMIT)
        demisto.debug(f"Get-events page {page_count}, requesting {remaining_limit} events")

        response = client.get_audit_logs(start_time=start_time, end_time=end_time, limit=remaining_limit, cursor=cursor)
        page_events = response.get("auditRecords", [])

        if not page_events:
            demisto.debug(f"No more events on page {page_count}, stopping")
            break

        all_events.extend(page_events)
        cursor = response.get("next_cursor")

        if not cursor:
            demisto.debug(f"No more pages after page {page_count}")
            break

    events = all_events
    demisto.debug(f"Get-events retrieved a total of {len(events)} events across {page_count} pages")

    hr = tableToMarkdown(
        name="Vercara UltraDNS Audit Events",
        t=events,
        removeNull=True,
    )

    command_results = CommandResults(readable_output=hr, outputs_prefix="VercaraUltraDNS.AuditEvents", outputs=events)

    if should_push_events:
        processed_events = process_events_for_xsiam(events)
        send_events_to_xsiam(processed_events, vendor=VENDOR, product=PRODUCT)
        xsiam_msg = f"\n\nSuccessfully sent {len(processed_events)} events to XSIAM."
        command_results.readable_output = (command_results.readable_output or "") + xsiam_msg
        demisto.debug(f"Successfully pushed {len(processed_events)} events to XSIAM")
    else:
        demisto.debug("Events displayed only, not pushed to XSIAM")

    return events, command_results


def fetch_events(
    client: Client, last_run: dict[str, Any], max_events_per_fetch: int
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
    """Fetch audit events with deduplication using hash-based approach.

    Note: UltraDNS API returns events in reverse chronological order (newest first).

    Args:
        client: UltraDNS client instance
        last_run: Previous run state containing last_fetch_time and event_cache
        max_events_per_fetch: Maximum number of events to fetch per cycle

    Returns:
        tuple[dict[str, Any], list[dict[str, Any]]]: Next run state and events (newest first)

    Raises:
        Exception: If event fetching fails
    """
    demisto.debug(f"Starting fetch with last_run: {last_run}")
    last_fetch_time_str = last_run.get("last_fetch_time")
    event_cache = last_run.get("event_cache", {})
    # Determine fetch time window
    if last_fetch_time_str:
        last_event_time = datetime.fromisoformat(last_fetch_time_str)
        # Since the api can filter only by seconds (not milliseconds),
        # we need to add a small overlap to ensure we don't miss any events
        start_time = last_event_time - timedelta(seconds=MARGIN_FETCH_OVERLAP_SECONDS)
        demisto.debug(
            f"Starting fetch from {last_event_time} with adding {MARGIN_FETCH_OVERLAP_SECONDS}s overlap, "
            f"{len(event_cache)} cached events"
        )
    else:
        last_event_time = None
        start_time = datetime.now() - timedelta(hours=3)
        demisto.debug("First fetch: collecting events from last 3 hours")

    end_time = datetime.now()

    # Initialize pagination variables
    raw_events: list[dict[str, Any]] = []
    cursor = None
    page_count = 0
    latest_event_time = last_event_time  # Will be updated with newest event from this fetch

    while len(raw_events) < max_events_per_fetch:
        page_count += 1
        remaining_limit = min(max_events_per_fetch - len(raw_events), PAGINATION_LIMIT)

        response = client.get_audit_logs(start_time=start_time, end_time=end_time, limit=remaining_limit, cursor=cursor)

        events = response.get("auditRecords", [])
        if not events:
            demisto.debug(f"No events returned on page {page_count}, stopping pagination")
            break

        demisto.debug(f"Page {page_count}: collected {len(events)} raw events")
        raw_events.extend(events)
        cursor = response.get("next_cursor")
        if not cursor:
            break

    demisto.debug(f"Pagination complete: collected {len(raw_events)} total raw events from {page_count} pages")

    unique_events = []
    if raw_events:
        # Remove duplicates only in boundary zone where overlaps can occur
        duplication_upper_bound = last_event_time + timedelta(seconds=MARGIN_DEDUP_SAFETY_SECONDS) if last_event_time else None
        unique_events = _deduplicate_events(raw_events, event_cache, duplication_upper_bound)

    # Process unique events (update time, cache, and format for XSIAM)
    if unique_events:
        # Update latest event time
        newest_event = unique_events[0]
        latest_event_time = convert_time_string(newest_event["changeTime"])
        demisto.debug(f"Latest event timestamp: {latest_event_time}")

        # Cache recent events and cleanup old cache entries using same cutoff
        cache_cutoff_time = latest_event_time - timedelta(seconds=MARGIN_FETCH_OVERLAP_SECONDS + MARGIN_DEDUP_SAFETY_SECONDS)
        _cache_recent_events(unique_events, event_cache, cache_cutoff_time)
        cleaned_cache = _cleanup_event_cache(event_cache, cache_cutoff_time)
        next_run_state = {"last_fetch_time": latest_event_time.strftime("%Y-%m-%dT%H:%M:%S"), "event_cache": cleaned_cache}

        unique_events = process_events_for_xsiam(unique_events)

        # Summary for events processed
        total_processed = len(raw_events)
        duplicates_filtered = total_processed - len(unique_events)
        demisto.debug(
            f"Fetch complete: {len(unique_events)} unique events from {total_processed} total "
            f"(filtered {duplicates_filtered} duplicates), next fetch from {latest_event_time}"
        )
    else:
        demisto.debug("No new events fetched, keeping last run unchanged")
        next_run_state = last_run

    return next_run_state, unique_events


def main() -> None:
    """Main entry point for UltraDNS integration.

    Returns:
        None

    Raises:
        Exception: If command execution fails, handled by return_error
    """

    params = demisto.params()
    command = demisto.command()
    args = demisto.args()

    base_url = params.get("url", "").rstrip("/")
    username = params.get("credentials", {}).get("identifier") or params.get("username", "")
    password = params.get("credentials", {}).get("password") or params.get("password", "")
    verify_certificate = not params.get("insecure", False)
    proxy = params.get("proxy", False)
    # Handle max_events_per_fetch with graceful fallback
    configured_limit = arg_to_number(params.get("max_events_per_fetch")) or MAX_EVENTS_PER_FETCH
    if configured_limit > MAX_EVENTS_PER_FETCH:
        demisto.info(
            f"Requested limit {configured_limit} exceeds maximum {MAX_EVENTS_PER_FETCH}. Using {MAX_EVENTS_PER_FETCH} instead."
        )
    max_events_per_fetch = min(configured_limit, MAX_EVENTS_PER_FETCH)

    try:
        client = Client(base_url=base_url, username=username, password=password, verify=verify_certificate, proxy=proxy)
        demisto.debug(f"Client initialized, executing command: {command}")

        if command == "test-module":
            result = test_module(client, params)
            return_results(result)

        elif command == "vercara-ultradns-get-events":
            events, results = get_events_command(client, args)
            return_results(results)

        elif command == "fetch-events":
            last_run = demisto.getLastRun()
            next_run, events = fetch_events(client=client, last_run=last_run, max_events_per_fetch=max_events_per_fetch)

            if events:
                send_events_to_xsiam(events, vendor=VENDOR, product=PRODUCT)
                demisto.debug(f"Successfully sent {len(events)} events to XSIAM")
            else:
                demisto.debug("No events to send to XSIAM")

            demisto.setLastRun(next_run)
            demisto.debug("Fetch-events cycle completed successfully, set last run: ", next_run)

        else:
            raise NotImplementedError(f"Command {command} is not implemented.")

    except Exception as e:
        demisto.error(traceback.format_exc())
        return_error(f"Failed to execute {command} command.\nError:\n{str(e)}")


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