iZOOlogic
Fetches and manages incidents from iZOOlogic, enabling automated ingestion, incident creation, and advanced filtering for brand protection and threat management.
Network Security · iZOOlogic
Details
| ID | iZOOlogic |
|---|---|
| Provider | iZOOlogic |
| Category | Network Security |
| From Version | 8.2.0 |
| Docker Image | demisto/fastapi:0.125.0.10158186 |
README
Fetches and manages incidents from iZOOlogic, enabling automated ingestion, incident creation, and advanced filtering for brand protection and threat management.
Configure iZOOlogic in Cortex
| Parameter | Description | Required |
|---|---|---|
| Server URL | The iZOOlogic API server URL. | True |
| API Key | The API key provided by iZOOlogic for authentication. | True |
| Secret Key | The secret key corresponding to the API key. | True |
| Trust any certificate (not secure) | Whether to trust any certificate (not secure). | False |
| Use system proxy settings | Whether to use the system proxy settings. | False |
| Fetch incidents | Whether to fetch incidents from iZOOlogic. | False |
| Fetch incident types | A comma-separated list of incident types to fetch from iZOOlogic. | True |
| Maximum incidents per fetch per type | The maximum number of incidents to fetch per type per fetch cycle. | True |
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.
izoologic-get-events
Gets events from iZOOlogic. 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
izoologic-get-events
Input
| Argument Name | Description | Required |
|---|---|---|
| limit | The maximum number of events to return per type. Default is 50. | Optional |
| start_time | The time to filter events detected at or after. Supports ISO 8601 format or relative time expressions (e.g., “3 days ago”, “2024-01-01T00:00:00Z”). | Optional |
| end_time | The time to filter events detected at or before. Supports ISO 8601 format or relative time expressions (e.g., “now”, “2024-01-01T00:00:00Z”). | Optional |
| event_type | The event types to filter by, as a comma-separated list. If not specified, the command uses the types configured in the integration parameters. Possible values are: brand abuse, phishing, malware, pharming, smishing, vishing, mobile apps, social media, other, email. | Optional |
| should_push_events | The flag that indicates whether to push events to Cortex XSIAM. Pushing events is supported on Cortex XSIAM only. When set to false, or on non-Cortex XSIAM platforms, events are displayed without being pushed. Possible values are: true, false. Default is false. | Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| iZOOlogic.Incident.incidentID | String | The unique identifier of the incident. |
| iZOOlogic.Incident.incidentType | String | The type and subtype of the incident. |
| iZOOlogic.Incident.subIncidentType | String | The subtype of the incident. |
| iZOOlogic.Incident.detectionDate | String | The detection date of the incident as a Unix timestamp. |
| iZOOlogic.Incident.url | String | The URL associated with the incident. |
| iZOOlogic.Incident.status | String | The current status of the incident. |
| iZOOlogic.Incident.statusCode | Number | The numeric status code of the incident. |
| iZOOlogic.Incident.brand | String | The brand associated with the incident. |
| iZOOlogic.Incident.threatType | String | The threat level of the incident. |
| iZOOlogic.Incident.createdOn | String | The creation date of the incident as a Unix timestamp. |
| iZOOlogic.Incident.closedOn | String | The closing date of the incident as a Unix timestamp. |
| iZOOlogic.Incident.detectedBy | String | The entity that detected the incident. |
Command example
!izoologic-get-events limit=3
Human Readable Output
iZOOlogic Events
Incident ID Incident Type Sub Incident Type Brand Url Status Status Code Threat Type Detection Date Created On Closed On Detected By uVJxla1s1 Brand Abuse - Fake Website Fake Website TVS Motor https://tvsmotor.com.mt Waiting 17 Substantial Threat 1760941801 1769509374 Reported By iZOOLogic 1JrJzZBip Phishing TVS Credit https://tvs-credit.dev.veefin.in Closed 16 High Threat 1769792260 1769792260 1770180062 Reported By iZOOLogic KIks8sE3U Social Media - Facebook TVS King https://www.facebook.com/ads/library/?id=917334661007536 Waiting 17 Substantial Threat 1769626014 1769626014 Reported By iZOOLogic
izoologic-incident-create
Creates a new security incident in iZOOlogic.
Base Command
izoologic-incident-create
Input
| Argument Name | Description | Required |
|---|---|---|
| incident_url | The URL, email, or target of the security incident (max 1000 characters). | Required |
| incident_type | The type of incident. Possible values are: brand abuse, phishing, malware, pharming, smishing, vishing, mobile apps, social media, other, email, executive. | Required |
| brand_code | The brand identifier associated with the incident. | Required |
| threat_type | The threat level. Possible values are: low threat, moderate threat, substantial threat, high threat, critical threat, redirect to whitelist. Default is moderate threat. | Optional |
| case_type | The preferred case type for processing. All new incidents are initially created as “Reported Incident” and may be reclassified during review. Possible values are: incident, brand abuse monitoring, domain monitoring, social media monitoring, mobile app monitoring, executive monitoring. Default is incident. | Optional |
| comment | The comments about the incident (max 2500 characters). | Optional |
| executive_name | The executive name. Required for executive-related incidents (max 2500 characters). | Optional |
| client_code | The client identifier for validation and access control. | Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| iZOOlogic.Incident.reportedIncidentId | String | The unique identifier for the created incident case. |
| iZOOlogic.Incident.statusCode | Number | The numeric status code (1 = under review). |
| iZOOlogic.Incident.statusDescription | String | The human-readable status description. |
| iZOOlogic.Incident.caseType | Number | The case type code (9 = reported incident). |
| iZOOlogic.Incident.caseTypeDescription | String | The human-readable case type description. |
Command example
!izoologic-incident-create incident_url="https://test-malicious-site.example.com" incident_type="phishing" brand_code="QnjggfvwlW"
Human Readable Output
iZOOlogic - New Incident Created
Reported Incident Id Status Code Status Description Case Type Case Type Description ycB2E7gPQ 1 Under Review 9 Reported Incident
izoologic-incident-fetch
Fetches incidents from iZOOlogic based on specified filters including date range, brand, incident type, and other criteria.
Base Command
izoologic-incident-fetch
Input
| Argument Name | Description | Required |
|---|---|---|
| from_date | The start date for filtering incidents. Supports ISO 8601 format or relative time expressions (e.g., “1 day ago”, “2024-01-01T00:00:00Z”). Maximum date range is 31 days. Default is 1 day ago. | Optional |
| to_date | The end date for filtering incidents. Supports ISO 8601 format or relative time expressions (e.g., “now”, “2024-01-01T00:00:00Z”). Maximum date range is 31 days. Default is now. | Optional |
| incident_type | The type of incident to filter by. Possible values are: brand abuse, phishing, malware, pharming, smishing, vishing, mobile apps, social media, other, email, executive. | Optional |
| threat_type | The threat level to filter by. Possible values are: low threat, moderate threat, substantial threat, high threat, critical threat, redirect to whitelist. | Optional |
| brand_code | The brand identifier to filter incidents by. | Optional |
| executive_name | The executive name for filtering executive-related incidents (max 100 characters). | Optional |
| client_ref_id | The client reference ID for specific incident lookup. | Optional |
| client_code | The client identifier for filtering incidents. | Optional |
Context Output
| Path | Type | Description |
|---|---|---|
| iZOOlogic.Incident.incidentID | String | The unique identifier of the incident. |
| iZOOlogic.Incident.incidentType | String | The type and subtype of the incident. |
| iZOOlogic.Incident.subIncidentType | String | The subtype of the incident. |
| iZOOlogic.Incident.detectionDate | String | The detection date of the incident as a Unix timestamp (e.g., 1704067200). |
| iZOOlogic.Incident.url | String | The URL associated with the incident. |
| iZOOlogic.Incident.status | String | The current status of the incident. |
| iZOOlogic.Incident.statusCode | Number | The numeric status code of the incident. |
| iZOOlogic.Incident.brand | String | The brand associated with the incident. |
| iZOOlogic.Incident.threatType | String | The threat level of the incident. |
| iZOOlogic.Incident.createdOn | String | The creation date of the incident as a Unix timestamp (e.g., 1704067200). |
| iZOOlogic.Incident.closedOn | String | The closing date of the incident as a Unix timestamp (e.g., 1704067200). |
| iZOOlogic.Incident.detectedBy | String | The entity that detected the incident. |
Command example
!izoologic-incident-fetch from_date="1 day ago" incident_type="phishing"
Human Readable Output
iZOOlogic Incidents
Incident ID Incident Type Brand Url Status Status Code Threat Type Detection Date Created On Detected By abc123 Phishing ExampleBrand https://example.com Active 1 High Threat 1700000000 1700000200 Reported By iZOOLogic
Configuration parameters
url— Server URL (required)api_key— (required)secret_key— (required)insecure— Trust any certificate (not secure)proxy— Use system proxy settingsisFetchEvents—events_types_filter— Fetch incident types (required)max_fetch— Maximum incidents per fetch per type (required)
Commands (3)
-
izoologic-get-eventsGets events from iZOOlogic. Use this command for development and debugging only, as it may produce duplicate events, exceed API rate limits, or disrupt the fetch mechanism.
-
izoologic-incident-createCreates a new security incident in iZOOlogic.
-
izoologic-incident-fetchFetches incidents from iZOOlogic based on specified filters including date range, brand, incident type, and other criteria.
import threading import traceback from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime, UTC from typing import Any import demistomock as demisto # noqa: F401 from CommonServerPython import * # noqa: F401 from CommonServerUserPython import * # noqa from ContentClientApiModule import * """ iZOOlogic Integration for fetching threat events from the iZOOlogic API. """ # region Constants and helpers # ================================= # Constants and helpers # ================================= INTEGRATION_NAME = "iZOOlogic" VENDOR = "iZOOlogic" PRODUCT = "iZOOlogic" class ApiPaths: """Centralized iZOOlogic API endpoint paths.""" AUTHENTICATE = "/api/Token/Authenticate" FETCH_EVENTS = "/api/ThreatManagement/FetchIncidents" REPORT_NEW_INCIDENT = "/api/ThreatManagement/ReportNewIncident" class Config: """Global static configuration.""" # Pagination DEFAULT_LIMIT = 50 DEFAULT_MAX_FETCH_PER_TYPE = 5000 # Fetch defaults DEFAULT_FROM_TIME = "1 minute ago" DEFAULT_FETCH_COMMAND_FROM_TIME = "1 day ago" # Max date range for API (31 days) MAX_DATE_RANGE_DAYS = 31 class TableHeaders: """Human-readable table header mappings (API key → display name).""" INCIDENT: dict[str, str] = { "incidentID": "Incident ID", "incidentType": "Incident Type", "subIncidentType": "Sub Incident Type", "brand": "Brand", "url": "URL", "status": "Status", "statusCode": "Status Code", "threatType": "Threat Type", "detectionDate": "Detection Date", "createdOn": "Created On", "closedOn": "Closed On", "detectedBy": "Detected By", } CREATE_INCIDENT: dict[str, str] = { "reportedIncidentId": "Reported Incident ID", "statusCode": "Status Code", "statusDescription": "Status Description", "caseType": "Case Type", "caseTypeDescription": "Case Type Description", } class ApiCodes: """Centralized API code mappings used across multiple commands.""" # API error codes NO_DATA_FOUND = "iZOO2011" # Mapping of incident type display names to API integer codes EVENT_TYPE: dict[str, int] = { "brand abuse": 1, "phishing": 2, "malware": 3, "pharming": 4, "smishing": 5, "vishing": 6, "mobile apps": 7, "social media": 8, "other": 9, "email": 23, "executive": 56, } # Mapping of threat type display names to API integer codes THREAT_TYPE: dict[str, int] = { "low threat": 10, "moderate threat": 11, "substantial threat": 12, "high threat": 13, "critical threat": 14, "redirect to whitelist": 48, } # Mapping of case type display names to API integer codes CASE_TYPE: dict[str, int] = { "incident": 6, "brand abuse monitoring": 2, "domain monitoring": 1, "social media monitoring": 4, "mobile app monitoring": 3, "executive monitoring": 5, } def date_to_unix_timestamp(date_input: str) -> str: """Parse a date string and return a Unix timestamp string for the iZOOlogic API. Args: date_input: Date string to parse (e.g., '3 days ago', '2024-01-01T00:00:00Z'). Returns: Unix timestamp as a string. """ parsed_dt = parse_date(date_input) timestamp = str(int(parsed_dt.timestamp())) demisto.debug(f"[Date Helper] Input: '{date_input}' -> Unix timestamp: '{timestamp}'") return timestamp def get_current_unix_timestamp() -> str: """Return the current UTC time as a Unix timestamp string. Returns: Current Unix timestamp as a string. """ timestamp = str(int(datetime.now(UTC).timestamp())) demisto.debug(f"[Date Helper] Current UTC Unix timestamp: '{timestamp}'") return timestamp def snap_to_day_boundary_utc(unix_timestamp: str, boundary: str = "start") -> str: """Snap a Unix timestamp to a UTC day boundary. The iZOOlogic API uses day-level filtering with the following rules: * ``fromdate`` is **rounded up** to the next UTC midnight (unless already at midnight). The resulting day is **inclusive**. * ``todate`` is **floored** to its UTC midnight. The resulting day is **inclusive**. * The effective range is ``[ceil(fromdate)_day, floor(todate)_day]``. Because ``fromdate`` rounds *up*, a non-midnight value skips the current day entirely. Snapping to ``"start"`` (midnight) prevents this. For same-day queries where ``fromdate == todate`` (both at midnight), the API rejects the request. Snapping ``todate`` to ``"end"`` (23:59:59) keeps it on the same day after flooring, producing ``[Day, Day]``. Args: unix_timestamp: Unix timestamp as a string. boundary: Either ``"start"`` (00:00:00) or ``"end"`` (23:59:59). Returns: Unix timestamp string snapped to the requested boundary of the same UTC day. """ dt = datetime.fromtimestamp(int(unix_timestamp), tz=UTC) if boundary == "end": snapped = dt.replace(hour=23, minute=59, second=59, microsecond=0) else: snapped = dt.replace(hour=0, minute=0, second=0, microsecond=0) result = str(int(snapped.timestamp())) demisto.debug(f"[DayBoundary:{boundary}] {unix_timestamp} -> {result}") return result def parse_date(date_string: str) -> datetime: """Parse a date string into a datetime object. Args: date_string: Date string to parse (e.g., '3 days ago', '2024-01-01T00:00:00Z'). Returns: Parsed datetime object in UTC. Raises: DemistoException: If the date string cannot be parsed. """ demisto.debug(f"[Date Helper] Attempting to parse date string: '{date_string}'") parsed_datetime = arg_to_datetime(arg=date_string, is_utc=True) if not parsed_datetime: raise DemistoException( f"Failed to parse date string: '{date_string}'. " "Please provide a valid date in ISO 8601 format (e.g., '2024-01-01T00:00:00Z') " "or a relative time expression (e.g., '3 days ago')." ) demisto.debug(f"[Date Helper] Final parsed date: {parsed_datetime.isoformat()}") return parsed_datetime def enrich_events(events: list[dict]) -> None: """Enrich events with the _time and source_log_type fields for XSIAM ingestion. Converts the ``createdOn`` Unix timestamp to ISO 8601 format and sets it as ``_time``. If ``createdOn`` is missing or cannot be parsed, ``_time`` falls back to the current UTC time so that every event is guaranteed to have a ``_time`` value. Also sets ``source_log_type`` to the event type for each event. Args: events: List of event dictionaries from the API. Modified in place. """ time_format = "%Y-%m-%dT%H:%M:%SZ" for event in events: created_on = event.get("createdOn", "") event_time = datetime.now(tz=UTC) if created_on: try: event_time = datetime.fromtimestamp(int(created_on), tz=UTC) except (ValueError, TypeError, OSError): demisto.debug(f"[Time] Failed to parse createdOn: {created_on}. Falling back to current time.") event["_time"] = event_time.strftime(time_format) event["source_log_type"] = event.get("incidentType", "Unknown") def create_events(events: list[dict]) -> None: """Send already-normalized events to XSIAM. Callers must normalize events with ``enrich_events`` beforehand so that each event has the ``_time`` and ``source_log_type`` fields set. Args: events: List of normalized event dictionaries. """ demisto.debug(f"[Create Events] Sending {len(events)} XSIAM events.") send_events_to_xsiam( events=events, vendor=VENDOR, product=PRODUCT, ) def filter_by_ids(raw_events: list[dict], ids_to_skip: list[str]) -> list[dict]: """Filter out events whose IDs are in the given skip set. Args: raw_events: List of event dictionaries from the API. ids_to_skip: List of event IDs to filter out. Returns: List of events not in the skip set. """ if not raw_events or not ids_to_skip: return raw_events skip_set = set(ids_to_skip) filtered = [inc for inc in raw_events if inc.get("incidentID") not in skip_set] skipped = len(raw_events) - len(filtered) if skipped > 0: demisto.debug(f"[Filter] Skipped {skipped} events by ID. {len(filtered)} remain.") return filtered def _validate_api_response(response: dict) -> dict: """Validate the API response and return the 'result' object. Handles known non-error codes like 'no data found' gracefully. Args: response: The full API response dictionary. Returns: The 'result' object from the response, or empty dict if no data found. Raises: DemistoException: If the API returned a real error. """ if not response.get("success", True): error_code = response.get("errorCode", "") message = response.get("message", "Unknown error") if error_code == ApiCodes.NO_DATA_FOUND: demisto.debug(f"[API] No data found for the given time range (errorCode: {error_code})") return {} raise DemistoException(f"API error: {message} (errorCode: {error_code})") result = response.get("result", {}) demisto.debug( f"[API Response] currentPage={result.get('currentPage')}, " f"totalRecords={result.get('totalRecords')}, " f"message={response.get('message')}, " f"errorCode={response.get('errorCode')}" ) return result def parse_integration_params(params: dict[str, Any]) -> dict[str, Any]: """Parse and validate integration configuration parameters. Args: params: Raw parameters from demisto.params(). Returns: Validated configuration dictionary. Raises: DemistoException: If required parameters are missing or invalid. """ base_url = params.get("url", "").rstrip("/") if not base_url: raise DemistoException("Server URL is required.") api_key_param = params.get("api_key", {}) api_key = api_key_param.get("password", "") if isinstance(api_key_param, dict) else api_key_param if not api_key: raise DemistoException("API Key is required.") secret_key_param = params.get("secret_key", {}) secret_key = secret_key_param.get("password", "") if isinstance(secret_key_param, dict) else secret_key_param if not secret_key: raise DemistoException("Secret Key is required.") verify_certificate = not argToBoolean(params.get("insecure", False)) proxy = argToBoolean(params.get("proxy", False)) is_fetch_events = argToBoolean(params.get("isFetchEvents", False)) # Parse and validate event types filter — default to all types if none specified event_types_filter = argToList(params.get("events_types_filter")) event_type_codes = resolve_type_codes(event_types_filter) if event_types_filter else list(ApiCodes.EVENT_TYPE.values()) max_fetch = int(params.get("max_fetch", Config.DEFAULT_MAX_FETCH_PER_TYPE)) if max_fetch <= 0: raise DemistoException(f"Invalid max_fetch value: {params.get('max_fetch')}. Must be a positive integer.") demisto.debug(f"[Config] URL: {base_url}") return { "base_url": base_url, "api_key": api_key, "secret_key": secret_key, "verify": verify_certificate, "proxy": proxy, "event_type_codes": event_type_codes, "max_fetch": max_fetch, "is_fetch_events": is_fetch_events, } def validate_date_range(from_date: str, to_date: str) -> None: """Validate that the date range is valid and does not exceed the API maximum of 31 days. Checks: 1. ``to_date`` must not be earlier than ``from_date`` (different-day inversion). 2. The span must not exceed ``Config.MAX_DATE_RANGE_DAYS`` (31 days). Note: same-day cases where ``to_date == from_date`` after snapping to midnight are handled separately by callers (e.g. ``get_events_command``). Args: from_date: Start date as Unix timestamp string. to_date: End date as Unix timestamp string. Raises: DemistoException: If the date range is inverted or exceeds 31 days. """ from_dt = datetime.fromtimestamp(int(from_date), tz=UTC) to_dt = datetime.fromtimestamp(int(to_date), tz=UTC) if to_dt.date() < from_dt.date(): raise DemistoException( f"'end_time' ({to_dt.isoformat()}) is before 'start_time' ({from_dt.isoformat()}). " "Please provide a valid date range where end_time >= start_time." ) if (to_dt - from_dt).days > Config.MAX_DATE_RANGE_DAYS: raise DemistoException( f"Date range exceeds the maximum of {Config.MAX_DATE_RANGE_DAYS} days. " f"From: {from_dt.isoformat()}, To: {to_dt.isoformat()}." ) def resolve_type_codes(type_names: list[str]) -> list[int]: """Resolve event type display names to API codes. Args: type_names: List of event type display names (e.g., ['phishing', 'malware']). Returns: List of API integer codes. Raises: DemistoException: If any type name is invalid. """ codes: list[int] = [] for name in type_names: code = ApiCodes.EVENT_TYPE.get(name.lower().strip()) if code is None: raise DemistoException(f"Invalid event type: '{name}'. Valid types: {list(ApiCodes.EVENT_TYPE.keys())}") codes.append(code) return codes # endregion # region Auth Handler # ================================= # Auth Handler # ================================= class IZOOlogicAuthHandler(AuthHandler): """Custom authentication handler for iZOOlogic two-step token auth. Flow: 1. POST to /api/Token/Authenticate with apikey + secretkey → get accesstoken 2. Set Authorization: Bearer <accesstoken> on each request 3. On 401, re-authenticate and retry """ name = "izoologic_token" def __init__(self, api_key: str, secret_key: str): self._api_key = api_key self._secret_key = secret_key self._token: str | None = None self._authenticating: bool = False self._auth_lock = threading.Lock() async def on_request(self, client: ContentClient, request: Any) -> None: """Add Bearer token to each request, authenticating if needed.""" if self._authenticating: return # Skip auth for the auth request itself to prevent recursion if not self._token: await self._authenticate(client) request.headers["Authorization"] = f"Bearer {self._token}" async def on_auth_failure(self, client: ContentClient, response: Any) -> bool: """Re-authenticate on 401 and retry the request.""" demisto.debug("[Auth] Authentication failed (401). Re-authenticating...") self._token = None # Clear expired token so _authenticate doesn't skip await self._authenticate(client) return True # Retry the request with new token async def _authenticate(self, client: ContentClient) -> None: """Authenticate with the iZOOlogic API and store the token. Uses a threading.Lock to ensure thread safety when concurrent fetches run via the ThreadPoolExecutor in fetch_events_command. The double-check on self._token prevents redundant auth calls when multiple threads queue up on the lock. """ with self._auth_lock: if self._token: demisto.debug("[Auth] Token already obtained by another thread, skipping.") return demisto.debug("[Auth] Authenticating with iZOOlogic API...") self._authenticating = True try: body = { "apikey": self._api_key, "secretkey": self._secret_key, } raw_response = await client._request( method="POST", url_suffix=ApiPaths.AUTHENTICATE, json_data=body, ) response: dict = raw_response.json() if not response.get("success", True): error_code = response.get("errorCode", "") message = response.get("message", "Unknown error") raise DemistoException( f"Authentication failed: {message} (errorCode: {error_code}). " "Verify your API Key and Secret Key are correct." ) result = response.get("result", {}) token = result.get("accessToken") if isinstance(result, dict) else None if not token: raise DemistoException( "Authentication failed: No token received from the API. " "Verify your API Key and Secret Key are correct." ) self._token = token demisto.debug("[Auth] Successfully authenticated") finally: self._authenticating = False # endregion # region Client # ================================= # Client # ================================= class Client(ContentClient): """iZOOlogic API client. Extends ContentClient with iZOOlogic-specific API methods. Authentication is handled automatically by IZOOlogicAuthHandler. """ def __init__( self, base_url: str, api_key: str, secret_key: str, verify: bool, proxy: bool, ): """Initialize the iZOOlogic client. Args: base_url: iZOOlogic API server URL. api_key: API key for authentication. secret_key: Secret key for authentication. verify: Whether to verify SSL certificates. proxy: Whether to use proxy settings. """ auth_handler = IZOOlogicAuthHandler(api_key, secret_key) super().__init__( base_url=base_url, verify=verify, proxy=proxy, auth_handler=auth_handler, client_name="iZOOlogic", ) def fetch_events_page( self, from_date: str, to_date: str, event_type: int | None = None, page_token: str | None = None, threat_type: int | None = None, brand_code: str | None = None, executive_name: str | None = None, client_ref_id: str | None = None, client_code: str | None = None, ) -> dict[str, Any]: """Fetch a single page of events from the iZOOlogic API. The API uses day-level filtering: ``fromdate`` is rounded up to the next UTC midnight (inclusive), ``todate`` is floored to its UTC midnight (inclusive). The effective range is ``[ceil(fromdate)_day, floor(todate)_day]``. Args: from_date: Start date as Unix timestamp string (must be midnight UTC to avoid the round-up skipping the intended day). to_date: End date as Unix timestamp string (floored to its day by the API). event_type: Optional event type code to filter by. page_token: Pagination token for retrieving the next page. threat_type: Optional threat level code to filter by. brand_code: Optional brand identifier to filter by. executive_name: Optional executive name to filter by. client_ref_id: Optional client reference ID for specific incident lookup. client_code: Optional client identifier to filter by. Returns: The 'result' object from the API response containing events and pagination info. """ body: dict[str, Any] = assign_params( fromdate=from_date, todate=to_date, incidenttype=event_type, token=page_token, threattype=threat_type, brandcode=brand_code, executivename=executive_name, clientrefid=client_ref_id, clientcode=client_code, ) demisto.debug(f"[API Fetch] Fetching events | Params: {body}") response = self._http_request( method="POST", url_suffix=ApiPaths.FETCH_EVENTS, json_data=body, ) return _validate_api_response(response) def report_new_incident( self, incident_url: str, incident_type: int, brand_code: str, threat_type: int | None = None, case_type: int | None = None, comment: str | None = None, executive_name: str | None = None, client_code: str | None = None, ) -> dict[str, Any]: """Report a new security incident to the iZOOlogic API. Args: incident_url: URL, email, or target of the security incident (max 1000 chars). incident_type: Type of incident (API integer code). brand_code: Brand identifier associated with the incident. threat_type: Optional threat level code. Defaults to moderate threat if not specified. case_type: Optional case type code. Defaults to incident (6) if not specified. comment: Optional comments about the incident (max 2500 chars). executive_name: Optional executive name for executive-related incidents (max 2500 chars). client_code: Optional client identifier for validation and access control. Returns: The full API response dictionary. """ body: dict[str, Any] = assign_params( incidenturl=incident_url, incidenttype=incident_type, brandcode=brand_code, threattype=threat_type, casetype=case_type, comment=comment, executivename=executive_name, clientcode=client_code, ) log_body = {**body, "executivename": "***"} if "executivename" in body else body demisto.debug(f"[API ReportNewIncident] Creating incident | Params: {log_body}") response = self._http_request( method="POST", url_suffix=ApiPaths.REPORT_NEW_INCIDENT, json_data=body, ) return response # endregion # region Command implementations # ================================= # Command implementations # ================================= def test_module(client: Client, event_type_codes: list[int], is_fetch_events: bool) -> str: """Test API connectivity, the event collector, and command functionality. Always validates the command path (the incident-search flow) so the integration's commands are verified regardless of configuration. The event collector is validated only when ``is_fetch_events`` is enabled — fetching a single event (``max_results=1``) per configured type via the same ``_fetch_all_pages`` code path as production. An empty result (no events) still proves connectivity. This catches a type the configured credentials cannot access, not just generic connectivity. Args: client: The iZOOlogic client. event_type_codes: Configured event type codes to validate. is_fetch_events: Whether event collection is enabled. Returns: 'ok' if test passed, otherwise raises an exception. """ demisto.debug(f"[Test Module] Starting (is_fetch_events={is_fetch_events})...") try: from_date = snap_to_day_boundary_utc(get_current_unix_timestamp(), "start") to_date = get_current_unix_timestamp() # Always validate command functionality by running the incident-search command, # using a narrow window (DEFAULT_FROM_TIME) to keep the test lightweight. demisto.debug("[Test Module] Validating command functionality (incident search)...") search_incidents_command(client, {"from_date": Config.DEFAULT_FROM_TIME}) # Validate the collector only when event collection is enabled. if is_fetch_events: demisto.debug(f"[Test Module] Validating collector for {len(event_type_codes)} configured type(s)...") for type_code in event_type_codes: _fetch_all_pages(client, from_date=from_date, to_date=to_date, event_type=type_code, max_results=1) demisto.debug("[Test Module] Success") return "ok" except Exception as error: error_msg = str(error) demisto.debug(f"[Test Module] Failed: {error_msg}") if "401" in error_msg or "403" in error_msg or "unauthorized" in error_msg.lower(): return "Authorization Error: Verify your API Key and Secret Key." raise def _fetch_all_pages( client: Client, from_date: str, to_date: str, event_type: int | None = None, threat_type: int | None = None, brand_code: str | None = None, executive_name: str | None = None, client_ref_id: str | None = None, client_code: str | None = None, max_results: int | None = None, from_ts: str | None = None, to_ts: str | None = None, last_ids: list[str] | None = None, ) -> list[dict]: """Fetch events for a single type: page, filter, dedup, sort, and cap. The API filters by whole UTC days and returns events newest-first. This function performs the full shared pipeline so all commands behave the same: 1. Page through the API, trimming each page to the precise ``[from_ts, to_ts]`` range (the API only filters by day). 2. Stop paging early once an event strictly older than ``from_ts`` appears (everything later is older too); events exactly at ``from_ts`` are kept. 3. Drop events whose IDs are in ``last_ids`` (already consumed at the watermark) — only when ``last_ids`` is provided. 4. Sort ascending by ``createdOn`` and keep the oldest ``max_results``, so callers always get the *earliest* events in the range (never the latest). Used by all commands: test_module, get_events_command, fetch_events_command, and search_incidents_command. Args: client: The iZOOlogic client. from_date: Start date as Unix timestamp string (must be midnight UTC). to_date: End date as Unix timestamp string. event_type: Optional event type code. threat_type: Optional threat level code to filter by. brand_code: Optional brand identifier to filter by. executive_name: Optional executive name to filter by. client_ref_id: Optional client reference ID for specific event lookup. client_code: Optional client identifier to filter by. max_results: Optional cap on the number of (oldest) events to return. from_ts: Optional precise lower bound (inclusive) as a Unix timestamp. to_ts: Optional precise upper bound (inclusive) as a Unix timestamp. last_ids: Optional IDs already consumed at ``from_ts`` to deduplicate. Returns: In-range events, sorted ascending by createdOn, capped to max_results. """ all_events: list[dict] = [] page_token: str | None = None page_count = 0 from_threshold = int(from_ts) if from_ts else None to_threshold = int(to_ts) if to_ts else None should_continue = True while should_continue: result_obj = client.fetch_events_page( from_date=from_date, to_date=to_date, event_type=event_type, page_token=page_token, threat_type=threat_type, brand_code=brand_code, executive_name=executive_name, client_ref_id=client_ref_id, client_code=client_code, ) page_events = result_obj.get("incidents") or [] if not page_events: break # Trim to the precise range (the API only filters by day). in_range = [event for event in page_events if _is_in_range(event, from_threshold, to_threshold)] all_events.extend(in_range) page_count += 1 demisto.debug( f"[FetchAll] Type {event_type or 'all'} | Page {page_count}: " f"+{len(in_range)} in-range events (total: {len(all_events)})" ) # Stop once an event is older than from_ts (newest-first). max_results is # applied after sorting, not here, so we get the oldest events. reached_watermark = from_threshold is not None and any( int(event.get("createdOn", "0")) < from_threshold for event in page_events ) if reached_watermark: demisto.debug(f"[FetchAll] Type {event_type or 'all'} | Reached lower-bound watermark, stopping pagination.") page_token = result_obj.get("nextPage") should_continue = bool(page_token) and not reached_watermark if last_ids: # drop IDs already consumed at the watermark (stateful callers) all_events = filter_by_ids(all_events, last_ids) all_events.sort(key=lambda event: int(event.get("createdOn", "0"))) # oldest first if max_results is not None: all_events = all_events[:max_results] demisto.debug(f"[FetchAll] Type {event_type or 'all'} | Done: {len(all_events)} events " f"across {page_count} pages") return all_events def _is_in_range(event: dict, from_threshold: int | None, to_threshold: int | None) -> bool: created_on = int(event.get("createdOn", "0")) if from_threshold is not None and created_on < from_threshold: return False return not (to_threshold is not None and created_on > to_threshold) def _resolve_fetch_window(from_input: str, to_input: str | None) -> tuple[str, str, str]: """Resolve a fetch time window shared by get-events and incident-fetch. Converts the inputs to Unix timestamps, snaps ``from_date`` to the start of its UTC day (the API filters by day), defaults ``to_date`` to now, validates the range, and snaps ``to_date`` to end-of-day when both land on the same day (otherwise the API rejects an equal from/to range). Args: from_input: Start time (ISO 8601 or relative). to_input: End time (ISO 8601 or relative), or None for now. Returns: Tuple of (from_ts, from_date, to_date) as Unix timestamp strings, where from_ts is the precise requested start (before the day snap). """ from_ts = date_to_unix_timestamp(from_input) from_date = snap_to_day_boundary_utc(from_ts, "start") to_date = date_to_unix_timestamp(to_input) if to_input else get_current_unix_timestamp() validate_date_range(from_date, to_date) # When from_date and to_date land on the same UTC day, snap to_date to # end-of-day so the API accepts the [Day, Day] range. if int(to_date) == int(from_date): to_date = snap_to_day_boundary_utc(to_date, "end") return from_ts, from_date, to_date def get_events_command( client: Client, args: dict, default_type_codes: list[int], ) -> CommandResults: """Manual command to get events for debugging/development. Args: client: The iZOOlogic client. args: Command arguments. default_type_codes: Default event type codes from integration config. Returns: CommandResults with the retrieved events. """ demisto.debug("[Command] izoologic-get-events triggered") should_push_events = resolve_should_push_events(args) limit = arg_to_number(args.get("limit", Config.DEFAULT_LIMIT)) if not limit or limit <= 0: raise DemistoException(f"Invalid limit value: {args.get('limit')}. Must be a positive integer.") limit = int(limit) start_time_input = args.get("start_time", Config.DEFAULT_FROM_TIME) end_time_input = args.get("end_time") # Use event_type from command args if provided, otherwise fall back to config event_type_arg = argToList(args.get("event_type")) type_codes = resolve_type_codes(event_type_arg) if event_type_arg else default_type_codes from_ts, from_date, to_date = _resolve_fetch_window(start_time_input, end_time_input) demisto.debug( f"[Command Params] From: {from_date} (requested: {from_ts}), To: {to_date}, Limit: {limit}, Types: {type_codes}" ) all_events: list[dict] = [] for type_code in type_codes: type_events = _fetch_all_pages( client, from_date=from_date, to_date=to_date, event_type=type_code, from_ts=from_ts, to_ts=to_date, max_results=limit, ) all_events.extend(type_events) demisto.debug(f"[Command Result] Total events retrieved: {len(all_events)} (limit per type: {limit})") # Normalize events (adds _time and source_log_type) so the command outputs # the same parsed events that are ingested into XSIAM. enrich_events(all_events) if should_push_events and all_events: create_events(list(all_events)) readable_output = tableToMarkdown( f"{INTEGRATION_NAME} Events", all_events, headers=list(TableHeaders.INCIDENT.keys()), headerTransform=TableHeaders.INCIDENT.get, removeNull=True, ) return CommandResults( readable_output=readable_output, outputs_prefix="iZOOlogic.Incident", outputs_key_field="incidentID", outputs=all_events, ) def _compute_new_state( consumed: list[dict], type_key: str, ) -> dict: """Compute the new last_run state from consumed events. Sets ``last_created_on`` to the maximum ``createdOn`` among the consumed events and collects all event IDs at that timestamp into ``last_ids`` for deduplication on the next run. Args: consumed: Events to consume (already sorted ascending and sliced to max_fetch). type_key: Type key string used for debug logging. Returns: Updated state dict with ``last_created_on`` and ``last_ids``. """ # Normalize createdOn to int to stay consistent with the int-based pipeline. max_created_on = int(consumed[-1].get("createdOn", "0")) new_last_ids = [inc.get("incidentID", "") for inc in consumed if int(inc.get("createdOn", "0")) == max_created_on] demisto.debug( f"[Fetch] Type {type_key}: {len(consumed)} events consumed. " f"New last_created_on={max_created_on}, dedup IDs={new_last_ids}." ) return { "last_created_on": max_created_on, "last_ids": new_last_ids, } def _fetch_for_type( client: Client, type_code: int, type_state: dict, max_fetch_per_type: int, ) -> tuple[str, list[dict], dict]: """Fetch events for a single type within a ≤31-day window. Uses ``Config.DEFAULT_FROM_TIME`` as the starting point on first fetch. The date range must not exceed 31 days (enforced by ``validate_date_range``). Args: client: The iZOOlogic client. type_code: Event type code. type_state: Per-type state from last_run. max_fetch_per_type: Maximum events to return for this type. Returns: Tuple of (type_key, consumed_events, updated_state). """ type_key = str(type_code) last_created_on: str | None = type_state.get("last_created_on") last_ids: list[str] = type_state.get("last_ids", []) effective_first_fetch = date_to_unix_timestamp(Config.DEFAULT_FROM_TIME) from_date = snap_to_day_boundary_utc( last_created_on if last_created_on else effective_first_fetch, "start", ) to_date = get_current_unix_timestamp() demisto.debug( f"[Fetch] Type {type_key}: from_date={from_date}, to_date={to_date}, " f"last_created_on={last_created_on}, last_ids({len(last_ids)})={last_ids}" ) from_ts = int(from_date) to_ts = int(to_date) if from_ts >= to_ts: demisto.debug(f"[Fetch] Type {type_key}: from_date >= to_date. Skipping.") return type_key, [], type_state validate_date_range(from_date, to_date) # _fetch_all_pages filters to >= last_created_on, dedups last_ids, sorts # ascending, and caps to the oldest max_fetch_per_type events. consumed = _fetch_all_pages( client, from_date, to_date, type_code, from_ts=last_created_on, last_ids=last_ids, max_results=max_fetch_per_type, ) if not consumed: demisto.debug(f"[Fetch] Type {type_key}: No new events.") return type_key, [], {"last_created_on": int(to_date), "last_ids": []} demisto.debug(f"[Fetch] Type {type_key}: Consuming {len(consumed)} events") updated_state = _compute_new_state(consumed, type_key) return type_key, consumed, updated_state def fetch_events_command( client: Client, max_fetch_per_type: int, event_type_codes: list[int], ) -> None: """Scheduled command to fetch events from iZOOlogic and send to XSIAM. Fetches all event types concurrently using a ThreadPoolExecutor (the API calls are blocking I/O, so threads parallelize them effectively). Each type maintains its own last_created_on and last_ids in last_run. For each type, ``_fetch_for_type`` delegates to ``_fetch_all_pages`` which pages the API, filters to the precise time window, stops early at the last_run watermark, dedups by last_ids, sorts ascending by createdOn, and caps to max_fetch_per_type. This command then enriches and pushes the consumed events and advances each type's last_run state. Args: client: The iZOOlogic client. max_fetch_per_type: Maximum number of events to fetch per type per run. event_type_codes: Event type codes from integration config. """ last_run = demisto.getLastRun() demisto.debug(f"[Fetch Events] Starting with last_run keys: {list(last_run.keys())}") # Launch concurrent fetches for all types. The client makes blocking HTTP # calls, so a thread pool parallelizes the per-type fetches effectively. all_events: list[dict] = [] updated_last_run: dict = dict(last_run) with ThreadPoolExecutor(max_workers=len(event_type_codes) or 1) as executor: future_to_type = { executor.submit( _fetch_for_type, client, type_code, last_run.get(str(type_code), {}), # type: ignore[arg-type] max_fetch_per_type, ): type_code for type_code in event_type_codes } for future in as_completed(future_to_type): try: type_key, consumed_events, updated_state = future.result() except Exception as error: # Isolate per-type failures so other types still succeed. demisto.error( f"[Fetch Events] Error fetching type {future_to_type[future]}: {error!s}\n" f"{traceback.format_exc()}" ) continue all_events.extend(consumed_events) updated_last_run[type_key] = updated_state if all_events: enrich_events(all_events) create_events(all_events) demisto.setLastRun(updated_last_run) demisto.debug(f"[Fetch Events] Done. Total events: {len(all_events)}. Last run updated.") def _resolve_code_by_name(raw_value: str, code_map: dict[str, int], field_name: str) -> int: """Resolve a display name to an API integer code. Args: raw_value: The user-provided display name. code_map: Mapping of display names to integer codes. field_name: Argument name used in error messages. Returns: The resolved integer code. Raises: DemistoException: If the name is not found in the code map. """ normalized = raw_value.lower().strip() code = code_map.get(normalized) if code is None: raise DemistoException(f"Invalid '{field_name}': '{raw_value}'. " f"Valid values: {list(code_map.keys())}.") return code def _validate_incident_creation_args(args: dict[str, Any]) -> dict[str, Any]: """Validate and parse arguments for the izoologic-incident-create command. Args: args: Raw command arguments from demisto.args(). Returns: Validated and parsed arguments dictionary ready for the API call. Raises: DemistoException: If required arguments are missing or invalid. """ # Required: incident_url incident_url = args.get("incident_url", "") if not incident_url: raise DemistoException("'incident_url' is a required argument.") # Required: incident_type (name only, mapped to integer code) incident_type_raw = args.get("incident_type", "") if not incident_type_raw: raise DemistoException("'incident_type' is a required argument.") incident_type = _resolve_code_by_name(str(incident_type_raw), ApiCodes.EVENT_TYPE, "incident_type") # Required: brand_code brand_code = args.get("brand_code", "") if not brand_code: raise DemistoException("'brand_code' is a required argument.") # Optional: threat_type (name only, mapped to integer code) threat_type: int | None = None threat_type_raw = args.get("threat_type") if threat_type_raw: threat_type = _resolve_code_by_name(str(threat_type_raw), ApiCodes.THREAT_TYPE, "threat_type") # Optional: case_type (name only, mapped to integer code) case_type: int | None = None case_type_raw = args.get("case_type") if case_type_raw: case_type = _resolve_code_by_name(str(case_type_raw), ApiCodes.CASE_TYPE, "case_type") # Optional: comment comment = args.get("comment") # Optional: executive_name executive_name = args.get("executive_name") # Optional: client_code client_code = args.get("client_code") return { "incident_url": incident_url, "incident_type": incident_type, "brand_code": brand_code, "threat_type": threat_type, "case_type": case_type, "comment": comment, "executive_name": executive_name, "client_code": client_code, } def create_incident_command(client: Client, args: dict[str, Any]) -> CommandResults: """Create a new security incident in iZOOlogic. Args: client: The iZOOlogic client. args: Command arguments from demisto.args(). Returns: CommandResults with the API response. """ demisto.debug("[Command] izoologic-incident-create triggered") validated_args = _validate_incident_creation_args(args) response = client.report_new_incident( incident_url=validated_args["incident_url"], incident_type=validated_args["incident_type"], brand_code=validated_args["brand_code"], threat_type=validated_args["threat_type"], case_type=validated_args["case_type"], comment=validated_args["comment"], executive_name=validated_args["executive_name"], client_code=validated_args["client_code"], ) if not response.get("success", False): error_code = response.get("errorCode", "") message = response.get("message", "Unknown error") raise DemistoException(f"Failed to create incident: {message} (errorCode: {error_code})") result = response.get("result", {}) readable_output = tableToMarkdown( f"{INTEGRATION_NAME} - New Incident Created", result, headers=list(TableHeaders.CREATE_INCIDENT.keys()), headerTransform=TableHeaders.CREATE_INCIDENT.get, removeNull=True, ) return CommandResults( readable_output=readable_output, outputs_prefix="iZOOlogic.Incident", outputs_key_field="reportedIncidentId", outputs=result, raw_response=response, ) def search_incidents_command(client: Client, args: dict[str, Any]) -> CommandResults: """Fetch incidents from iZOOlogic with advanced filtering. Retrieves incidents based on specified filters including date range, brand, incident type, threat type, and other criteria. Supports pagination for large result sets. Args: client: The iZOOlogic client. args: Command arguments from demisto.args(). Returns: CommandResults with the retrieved incidents. """ demisto.debug("[Command] izoologic-incident-fetch triggered") # Date range — default: 1 day ago to now from_date_input = args.get("from_date", Config.DEFAULT_FETCH_COMMAND_FROM_TIME) to_date_input = args.get("to_date") from_ts, from_date, to_date = _resolve_fetch_window(from_date_input, to_date_input) # Optional: incident_type (name only, mapped to integer code) incident_type: int | None = None incident_type_raw = args.get("incident_type") if incident_type_raw: incident_type = _resolve_code_by_name(str(incident_type_raw), ApiCodes.EVENT_TYPE, "incident_type") # Optional: threat_type (name only, mapped to integer code) threat_type: int | None = None threat_type_raw = args.get("threat_type") if threat_type_raw: threat_type = _resolve_code_by_name(str(threat_type_raw), ApiCodes.THREAT_TYPE, "threat_type") # Optional string filters brand_code = args.get("brand_code") executive_name = args.get("executive_name") client_ref_id = args.get("client_ref_id") client_code = args.get("client_code") demisto.debug( f"[Command Params] From: {from_date}, To: {to_date}, " f"IncidentType: {incident_type}, ThreatType: {threat_type}, " f"BrandCode: {brand_code}, ExecutiveName: {executive_name}, " f"ClientRefId: {client_ref_id}, ClientCode: {client_code}" ) all_incidents = _fetch_all_pages( client, from_date=from_date, to_date=to_date, event_type=incident_type, threat_type=threat_type, brand_code=brand_code, executive_name=executive_name, client_ref_id=client_ref_id, client_code=client_code, from_ts=from_ts, to_ts=to_date, ) demisto.debug(f"[Command Result] Total incidents retrieved: {len(all_incidents)}") readable_output = tableToMarkdown( f"{INTEGRATION_NAME} Incidents", all_incidents, headers=list(TableHeaders.INCIDENT.keys()), headerTransform=TableHeaders.INCIDENT.get, removeNull=True, ) return CommandResults( readable_output=readable_output, outputs_prefix="iZOOlogic.Incident", outputs_key_field="incidentID", outputs=all_incidents, raw_response=all_incidents, ) # endregion # region Command Map and Main # ================================= # Command Map and Main # ================================= COMMAND_MAP: dict[str, Any] = { "test-module": test_module, "izoologic-get-events": get_events_command, "fetch-events": fetch_events_command, "izoologic-incident-create": create_incident_command, "izoologic-incident-fetch": search_incidents_command, } def main() -> None: """Main entry point for iZOOlogic integration.""" demisto.debug(f"{INTEGRATION_NAME} integration started") command = demisto.command() try: if command not in COMMAND_MAP: raise DemistoException(f"Command '{command}' is not implemented") params = demisto.params() args = demisto.args() config = parse_integration_params(params) client = Client( base_url=config["base_url"], api_key=config["api_key"], secret_key=config["secret_key"], verify=config["verify"], proxy=config["proxy"], ) command_func = COMMAND_MAP[command] if command == "test-module": result = command_func(client, config["event_type_codes"], config["is_fetch_events"]) return_results(result) elif command == "fetch-events": command_func(client, config["max_fetch"], config["event_type_codes"]) elif command == "izoologic-get-events": result = command_func(client, args, config["event_type_codes"]) return_results(result) elif command in ("izoologic-incident-create", "izoologic-incident-fetch"): result = command_func(client, args) return_results(result) except Exception as error: error_msg = f"Failed to execute {command}. Error: {error!s}" demisto.error(f"{error_msg}\n{traceback.format_exc()}") return_error(error_msg) demisto.debug(f"{INTEGRATION_NAME} integration finished") if __name__ in ("__main__", "__builtin__", "builtins"): main() # endregion