CybelAngel Event Collector

CybelAngel collects reports from the CybelAngel platform, which specializes in external attack surface protection and management.

Data Enrichment & Threat Intelligence · CybelAngel

Details

IDCybelAngel Event Collector
ProviderCybelAngel
CategoryData Enrichment & Threat Intelligence
From Version8.0.0
Docker Imagedemisto/python3:3.12.13.10116658
Supported ModulesAgentix XSIAM

README

CybelAngel receives reports from the CybelAngel platform, which specializes in external attack surface protection and management

Configure CybelAngel in Cortex

Required Modules (For XSIAM)

  • Account Takeover Protection module is required for Credentials watchlist events.

  • Domain Protection module is required for Domain events.

Parameter Required
Server URL True
Client ID True
Client Secret True
Trust any certificate (not secure) False
Use system proxy settings False
Fetch events True
Event Types To Fetch True
The maximum number of report event per fetch False
The maximum number of Credential watchlist events per fetch False
The maximum number of Domain watchlist events per fetch False

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.

cybelangel-get-events


Send events from CybelAngel to XSIAM. Used mainly for debugging.

Base Command

cybelangel-get-events

Input

Argument Name Description Required
should_push_events Set to True in order to create events, otherwise will only display them. Possible values are: True, False. Default is False. Required
events_type_to_fetch Which events to fetch. Possible values are: Reports, Domain wathclish, Credentials watchlist. Default is Reports. Optional
start_date Get events from a specific start date formatted with ISO 8601, Example: “2025-01-14T10:22:45”. Optional
end_date Get events until a specific end date formatted with ISO 8601, Example: “2025-01-14T10:22:45”. Optional
limit The maximum number of events to return. Default is 50. Optional

Context Output

There is no context output for this command.

cybelangel-report-status-update


Update the status of one or multiple reports.

Base Command

cybelangel-report-status-update

Input

Argument Name Description Required
report_ids List of report IDs to update. Required
status The new status of the reports. Possible values are: draft, open, in_progress, resolved, discarded. Required

Context Output

There is no context output for this command.

Command example

!cybelangel-report-status-update report_ids=1234 status=open

Human Readable Output

“The status of the following reports </report list> has been successfully updated to </report status>.”

cybelangel-report-get


Retrieve reports from CybelAngel.

Base Command

cybelangel-report-get

Input

Argument Name Description Required
report_id The ID of the report to retrieve. Required
pdf If true, retrieves the report as a PDF file. Possible values are: True, False. Optional

Context Output

Path Type Description
CybelAngel.Report unknown The retrieved report.
InfoFile.EntryID String Entry ID of the saved PDF file.

Command example

!cybelangel-report-get report_id=1234

Context Example

{
    "CybelAngel": {
        "Report": {
            "abstract": "Example Output.",
            "abuse_email": "",
            "analysis": "Example Output.",
            "asset_urls": [],
            "attachments": [
                {
                    "attached_to": "report_id",
                    "id": "1234",
                    "name": "Example Output.csv"
                }
            ],
            "board": "",
            "category": "leak",
            "city": "",
            "country_code": "",
            "created_at": "2000-11-26T13:25:16.116453",
            "detected_at": "2000-11-26T10:45:05+00:00",
            "domain_registered_at": null,
            "hostnames": [],
            "id": "1234",
            "incident_id": "1234",
            "incident_type": "Test",
            "investigation_id": "1234:1234",
            "ip": "",
            "keywords": [
                {
                    "id": "1234",
                    "name": "aa.net"
                }
            ],
            "liveness": {
                "last_checked_at": "2000-11-26T13:25:15.716702+00:00",
                "online": true
            },
            "location": "",
            "machine_name": null,
            "malware_location": null,
            "malware_name": null,
            "module": "account_Test",
            "mx_servers": [],
            "ns_servers": [],
            "origins": [
                {
                    "type": "malicious_actor",
                    "value": ""
                }
            ],
            "port": null,
            "registrant_email": "",
            "registrar_name": "",
            "report_content": "Example Output.",
            "report_type": "incident_detection",
            "risks": [
                {
                    "message": "Example Output.",
                    "type": "account_takeover"
                },
                {
                    "message": "Example Output.",
                    "type": "spear_phishing"
                },
                {
                    "message": "Example Output.",
                    "type": "social_engineering"
                }
            ],
            "samples": [
                {
                    "sample": "See attachment",
                    "type": "other"
                }
            ],
            "screenshots": [],
            "sender": "Example Output@cybelangel.com",
            "sender_tenant_id": "cybelangel",
            "sent_at": "20200-11-26T13:25:57+00:00",
            "severity": 1,
            "source": "Example platform",
            "status": "resolved",
            "stream": "1234",
            "suggestions": [
                {
                    "message": "Example Output.",
                    "type": "other"
                }
            ],
            "tags": [],
            "threat": null,
            "title": "Example Output. platform",
            "updated_at": "2000-02-23T13:07:17.214040",
            "url": "https://platform.cybelangel.com/reports/1234",
            "user_session": null,
            "usergroups": [
                "Example Output.",
                "TVMExample Output.SOC"
            ],
            "volume": {
                "bins": null,
                "documents": null,
                "domain": null,
                "emails": 1,
                "ips": null,
                "passwords": 1
            },
            "whois": ""
        }
    }
}

Human Readable Output

Report ID example-id-6 details

id report_type sender severity status updated_at
example-id-6 incident_detection example@example.com 1 in_progress 2025-03-03T09:13:33.253781

Command example

!cybelangel-report-get report_id=1234 pdf=true

Context Example

{
    "InfoFile": {
        "EntryID": "1234",
        "Extension": "pdf",
        "Info": "application/pdf",
        "Name": "cybelangel_report_1234.pdf",
        "Size": 127719,
        "Type": "PDF document, version 1.4"
    }
}

Human Readable Output

Returned file: cybelangel_report_1234.pdf

cybelangel-report-remediation-request-create


Create a remediation request for a report.

Base Command

cybelangel-report-remediation-request-create

Input

Argument Name Description Required
report_id The ID of the report. Required
requestor_email Email of the requestor. Required
requestor_fullname Full name of the requestor. Required

Context Output

There is no context output for this command.

Command example

!cybelangel-report-remediation-request-create report_id=1234 requestor_email=test@paloaltonetworks.com requestor_fullname="Example Test"

Context Example

{
    "CybelAngel": {
        "Report": {
            "RemediationRequest": {
                "report_id": "1234"
            }
        }
    }
}

Human Readable Output

Remediation request was created for 1234.

cybelangel-report-attachment-get


Retrieve an attachment from a report.

Base Command

cybelangel-report-attachment-get

Input

Argument Name Description Required
report_id The ID of the report. Required
attachment_id The ID of the attachment. Required

Context Output

Path Type Description
InfoFile.EntryID unknown Entry ID of the retrieved file.

Command example

!cybelangel-report-attachment-get report_id=1234 attachment_id=5678

Context Example

{
    "InfoFile": {
        "EntryID": "1111",
        "Extension": "csv",
        "Info": "text/csv; charset=utf-8",
        "Name": "cybelangel_report_1234_attachment_5678.csv",
        "Size": 210,
        "Type": "ASCII text"
    }
}

Human Readable Output

cybelangel-archive-report-by-id-get


Retrieve an archived report by ID as a ZIP file.

Base Command

cybelangel-archive-report-by-id-get

Input

Argument Name Description Required
report_id The ID of the archived report. Required

Context Output

Path Type Description
InfoFile.EntryID unknown Entry ID of the saved ZIP file.

Command example

!cybelangel-archive-report-by-id-get report_id=1234

Context Example

{
    "InfoFile": {
        "EntryID": "1111",
        "Extension": "zip",
        "Info": "application/zip",
        "Name": "cybelangel_archive_report_1234.zip",
        "Size": 15604,
        "Type": "Zip archive data, at least v2.0 to extract"
    }
}

Human Readable Output

cybelangel-mirror-report-get


Retrieve the mirror details for the specified report.

Base Command

cybelangel-mirror-report-get

Input

Argument Name Description Required
report_id The ID of the report. Required
csv If true, retrieves the mirror report in CSV format. Possible values are: True, False. Optional

Context Output

Path Type Description
CybelAngel.ReportMirror unknown Mirror details of the report.
InfoFile.EntryID unknown Entry ID of the saved CSV file.

Command example

!cybelangel-mirror-report-get report_id=1234

Context Example

{
    "CybelAngel": {
        "ReportMirror": {
            "available_files_count": 1,
            "created_at": "2000-07-11T12:50:20Z",
            "files_count": 1,
            "files_volume": 6871,
            "report_id": "1234",
            "status": "expired",
            "stream_id": "1234",
            "updated_at": "2000-01-12T03:26:49Z"
        }
    }
}

Human Readable Output

Mirror details for Report ID example-id-7

report_id created_at available_files_count updated_at
example-id-7 2024-07-11T12:50:20Z 1 2025-01-12T03:26:49Z

Command example

!cybelangel-mirror-report-get report_id=1234 csv=true

Context Example

{
    "InfoFile": {
        "EntryID": "1111",
        "Extension": "csv",
        "Info": "text/csv; charset=utf-8",
        "Name": "cybelangel_mirror_report_1234.csv",
        "Size": 212,
        "Type": "ASCII text"
    }
}

Human Readable Output

cybelangel-report-comment-create


Create a new comment on a report.

Base Command

cybelangel-report-comment-create

Input

Argument Name Description Required
discussion_id The discussion_id is made of report id and tenant id like uuid:uuid. Example: [report_id]:[your-tenant-id]. Required
content The content of the comment. Required
parent_id The ID of the parent comment (for replies). Optional
assigned Specifies if the comment is assigned to analysts (true/false). Optional

Context Output

There is no context output for this command.

Command example

!cybelangel-report-comment-create report_id=1234 content="Test Comment"

Human Readable Output

Comment added to Report ID 1234.

cybelangel-report-list


Retrieve reports from CybelAngel.

Base Command

cybelangel-report-list

Input

Argument Name Description Required
start_date Get reports from a specific start date formatted with ISO 8601. Optional
end_date Get reports until a specific end date formatted with ISO 8601. Optional

Context Output

Path Type Description
CybelAngel.Report unknown The retrieved reports.

Command example

!cybelangel-report-list start_date="19 hours ago" end_date="now"

Context Example

{
    "CybelAngel": {
        "Report": {
            "reports": [
                {
            "abstract": "Example Output.",
            "abuse_email": "",
            "analysis": "Example Output.",
            "asset_urls": [],
            "attachments": [
                {
                    "attached_to": "report_id",
                    "id": "1234",
                    "name": "Example Output.csv"
                }
            ],
            "board": "",
            "category": "leak",
            "city": "",
            "country_code": "",
            "created_at": "2000-11-26T13:25:16.116453",
            "detected_at": "2000-11-26T10:45:05+00:00",
            "domain_registered_at": null,
            "hostnames": [],
            "id": "1234",
            "incident_id": "1234",
            "incident_type": "Test",
            "investigation_id": "1234:1234",
            "ip": "",
            "keywords": [
                {
                    "id": "1234",
                    "name": "aa.net"
                }
            ],
            "liveness": {
                "last_checked_at": "2000-11-26T13:25:15.716702+00:00",
                "online": true
            },
            "location": "",
            "machine_name": null,
            "malware_location": null,
            "malware_name": null,
            "module": "account_Test",
            "mx_servers": [],
            "ns_servers": [],
            "origins": [
                {
                    "type": "malicious_actor",
                    "value": ""
                }
            ],
            "port": null,
            "registrant_email": "",
            "registrar_name": "",
            "report_content": "Example Output.",
            "report_type": "incident_detection",
            "risks": [
                {
                    "message": "Example Output.",
                    "type": "account_takeover"
                },
                {
                    "message": "Example Output.",
                    "type": "spear_phishing"
                },
                {
                    "message": "Example Output.",
                    "type": "social_engineering"
                }
            ],
            "samples": [
                {
                    "sample": "See attachment",
                    "type": "other"
                }
            ],
            "screenshots": [],
            "sender": "Example Output@cybelangel.com",
            "sender_tenant_id": "cybelangel",
            "sent_at": "20200-11-26T13:25:57+00:00",
            "severity": 1,
            "source": "Example platform",
            "status": "resolved",
            "stream": "1234",
            "suggestions": [
                {
                    "message": "Example Output.",
                    "type": "other"
                }
            ],
            "tags": [],
            "threat": null,
            "title": "Example Output. platform",
            "updated_at": "2000-02-23T13:07:17.214040",
            "url": "https://platform.cybelangel.com/reports/1234",
            "user_session": null,
            "usergroups": [
                "Example Output.",
                "TVMExample Output.SOC"
            ],
            "volume": {
                "bins": null,
                "documents": null,
                "domain": null,
                "emails": 1,
                "ips": null,
                "passwords": 1
            },
            "whois": ""
        },
        {
            "abstract": "Example Output.",
            "abuse_email": "",
            "analysis": "Example Output.",
            "asset_urls": [],
            "attachments": [
                {
                    "attached_to": "report_id",
                    "id": "1234",
                    "name": "Example Output.csv"
                }
            ],
            "board": "",
            "category": "leak",
            "city": "",
            "country_code": "",
            "created_at": "2000-11-26T13:25:16.116453",
            "detected_at": "2000-11-26T10:45:05+00:00",
            "domain_registered_at": null,
            "hostnames": [],
            "id": "1234",
            "incident_id": "1234",
            "incident_type": "Test",
            "investigation_id": "1234:1234",
            "ip": "",
            "keywords": [
                {
                    "id": "1234",
                    "name": "aa.net"
                }
            ],
            "liveness": {
                "last_checked_at": "2000-11-26T13:25:15.716702+00:00",
                "online": true
            },
            "location": "",
            "machine_name": null,
            "malware_location": null,
            "malware_name": null,
            "module": "account_Test",
            "mx_servers": [],
            "ns_servers": [],
            "origins": [
                {
                    "type": "malicious_actor",
                    "value": ""
                }
            ],
            "port": null,
            "registrant_email": "",
            "registrar_name": "",
            "report_content": "Example Output.",
            "report_type": "incident_detection",
            "risks": [
                {
                    "message": "Example Output.",
                    "type": "account_takeover"
                },
                {
                    "message": "Example Output.",
                    "type": "spear_phishing"
                },
                {
                    "message": "Example Output.",
                    "type": "social_engineering"
                }
            ],
            "samples": [
                {
                    "sample": "See attachment",
                    "type": "other"
                }
            ],
            "screenshots": [],
            "sender": "Example Output@cybelangel.com",
            "sender_tenant_id": "cybelangel",
            "sent_at": "20200-11-26T13:25:57+00:00",
            "severity": 1,
            "source": "Example platform",
            "status": "resolved",
            "stream": "1234",
            "suggestions": [
                {
                    "message": "Example Output.",
                    "type": "other"
                }
            ],
            "tags": [],
            "threat": null,
            "title": "Example Output. platform",
            "updated_at": "2000-02-23T13:07:17.214040",
            "url": "https://platform.cybelangel.com/reports/1234",
            "user_session": null,
            "usergroups": [
                "Example Output.",
                "TVMExample Output.SOC"
            ],
            "volume": {
                "bins": null,
                "documents": null,
                "domain": null,
                "emails": 1,
                "ips": null,
                "passwords": 1
            },
            "whois": ""
        }
            ]
        }
    }
}

Human Readable Output

Reports list

id url report_type sender severity status updated_at report_content
example-id-1 https://platform.example.com/reports/example-id-1 incident_detection example@example.com 1 open 2025-02-25T13:06:06.821922 ### Sample content… Example
example-id-2 https://platform.example.com/reports/example-id-2 incident_detection example@example.com 1 resolved 2025-02-26T18:58:50.303598 ### Sample content… Example
example-id-3 https://platform.example.com/reports/example-id-3 incident_detection example@example.com 1 in_progress 2025-02-26T12:17:42.241832 ### Sample content… Example
example-id-4 https://platform.example.com/reports/example-id-4 incident_detection example@example.com 1 open 2025-02-26T13:29:54.520708 ### Sample content… Example
example-id-5 https://platform.example.com/reports/example-id-5 incident_detection example@example.com 2 open 2025-02-25T16:29:32.696281 ### Sample content… Example
example-id-6 https://platform.example.com/reports/example-id-6 incident_detection example@example.com 1 in_progress 2025-03-03T09:13:33.253781 ### Sample content… Example
example-id-7 https://platform.example.com/reports/example-id-7 incident_detection example@example.com 1 in_progress 2025-03-03T09:13:33.253781 ### Sample content… Example
example-id-8 https://platform.example.com/reports/example-id-8 incident_detection example@example.com 1 open 2025-03-03T14:26:11.424002 ### Sample content… Example
example-id-9 https://platform.example.com/reports/example-id-9 incident_detection example@example.com 1 open 2025-03-03T14:22:14.184243 ### Sample content… Example
example-id-10 https://platform.example.com/reports/example-id-10 incident_detection example@example.com 1 open 2025-03-03T14:28:22.089922 ### Sample content… Example

cybelangel-report-comments-get


Retrieve comments from a report.

Base Command

cybelangel-report-comments-get

Input

Argument Name Description Required
report_id The ID of the report. Required

Context Output

Path Type Description
CybelAngel.Report.Comment unknown The list of comments for the report.

Command example

!cybelangel-report-comments-get report_id=1234

Context Example

{
    "CybelAngel": {
        "Report": {
            "Comments": {
                "comments": [
                    {
                        "assigned": false,
                        "author": {
                            "firstname": "Example",
                            "id": "1234",
                            "lastname": "Test"
                        },
                        "content": "Test Comment 2",
                        "created_at": "2000-07-11T15:29:05Z",
                        "discussion_id": "1234:5678",
                        "discussion_tenant_name": "Test",
                        "id": "1234",
                        "isNew": false,
                        "last_updated_at": "2000-07-11T15:29:05Z"
                    },
                    {
                        "assigned": false,
                        "author": {
                            "firstname": "Example",
                            "id": "1234",
                            "lastname": "Test"
                        },
                        "content": "Test Comment 2",
                        "created_at": "2000-07-11T15:29:05Z",
                        "discussion_id": "1234:5678",
                        "discussion_tenant_name": "Test",
                        "id": "1234",
                        "isNew": false,
                        "last_updated_at": "2000-07-11T15:29:05Z"
                    }
                ],
                "new": 0,
                "total": 2,
                "id": "1234"
            }
        }
    }
}

Human Readable Output

Comments for Report ID example-id-8

content created_at discussion_id assigned author_firstname author_lastname last_updated_at
This is a comment message 2025-02-27T11:04:05Z example-id-8:example-tenant-id false ExampleFirst ExampleLast 2025-02-27T11:04:05Z

<~PLATFORM>

License Requirements

The following configuration parameters require the Cortex XSIAM license:

  • Fetch events

</~PLATFORM>

Configuration parameters

  • url — Server URL (required)
  • credentials — Client ID (required)
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings
  • isFetchEvents — Fetch events
  • eventFetchInterval — Events Fetch Interval
  • event_types_to_fetch — Event Types To Fetch
  • max_fetch — The maximum number of reports per fetch (required)
  • max_fetch_creds — The maximum number of Credential watchlist events per fetch
  • max_fetch_domain — The maximum number of Domain watchlist events per fetch

Commands (10)

  • cybelangel-archive-report-by-id-get

    Retrieve an archived report by ID as a ZIP file.

  • cybelangel-get-events

    Send events from CybelAngel to XSIAM. Used mainly for debugging.

  • cybelangel-mirror-report-get

    Retrieve the mirror details for the specified report.

  • cybelangel-report-attachment-get

    Retrieve an attachment from a report.

  • cybelangel-report-comment-create

    Create a new comment on a report.

  • cybelangel-report-comments-get

    Retrieve comments from a report.

  • cybelangel-report-get

    Retrieve reports from CybelAngel.

  • cybelangel-report-list

    Retrieve reports from CybelAngel.

  • cybelangel-report-remediation-request-create

    Create a remediation request for a report.

  • cybelangel-report-status-update

    Update the status of one or multiple reports.

import dateparser

import demistomock as demisto  # noqa: F401
from CommonServerPython import *  # noqa: F401

from CommonServerUserPython import *  # noqa

import urllib3
from typing import Any
from requests import Response
from datetime import datetime, timedelta

# Disable insecure warnings
urllib3.disable_warnings()

DATE_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"

VENDOR = "cybelangel"
PRODUCT = "platform"


class EventType:
    """
    This class defines a CybelAngel API Event - used to dynamically store
    per-type settings for fetching and deduplicating events.
    """

    def __init__(
        self,
        name: str,
        url_suffix: str,
        id_key: Union[str, List[str]],
        ascending_order: bool,
        time_field: str,
        source_log_type: str,
        default_max_fetch: int,
    ):
        """
        Args:
            name                     (str): Human-friendly name of the event type, as in the params names..
            url_suffix               (str): URL suffix of the CybelAngel API endpoint.
            id_key (Union[str, List[str]]): Key or list of keys used to uniquely identify an event.
            ascending_order         (bool): Events sorted by ascending/descending order after returning from get function.
            time_field               (str): Field name in the event used for timestamp mapping (`_time`).
            source_log_type          (str): Value to assign to each event's `source_log_type` field in XSIAM.
            default_max_fetch        (int): Default max_fetch limit.
        """
        self.name = name
        self.url_suffix = url_suffix
        self.max_fetch = 1
        self.id_key = id_key
        self.ascending_order = ascending_order
        self.max_index = -1 if ascending_order else 0
        self.time_field = time_field
        self.source_log_type = source_log_type
        self.default_max_fetch = default_max_fetch

    def get_id(self, event: Dict[str, Any]) -> str:
        """Return unique id by the id_key fields"""
        if isinstance(self.id_key, list):
            return "".join(str(event[k]) for k in self.id_key)
        return str(event.get(self.id_key, ""))


REPORT = EventType(
    name="Reports",
    url_suffix="/api/v2/reports",
    id_key="id",
    ascending_order=True,
    time_field="updated_at",
    source_log_type="Report",
    default_max_fetch=5000,
)
CREDENTIALS = EventType(
    name="Credential watchlist",
    url_suffix="/api/v1/credentials",
    id_key=["last_detection_date", "email"],
    ascending_order=True,
    time_field="last_detection_date",
    source_log_type="Credential watchlist",
    default_max_fetch=50,
)
DOMAIN = EventType(
    name="Domain watchlist",
    url_suffix="/api/v1/domains",
    id_key=["detection_date", "domain"],
    ascending_order=False,
    time_field="detection_date",
    source_log_type="Domain watchlist",
    default_max_fetch=500,
)

EVENT_TYPE = {"Reports": REPORT, "Credential watchlist": CREDENTIALS, "Domain watchlist": DOMAIN}

LATEST_TIME = "latest_time"
LATEST_FETCHED_IDS = "latest_fetched_ids"


class Client(BaseClient):
    def __init__(
        self,
        base_url: str,
        client_id: str,
        client_secret: str,
        verify: bool,
        proxy: bool,
        **kwargs,
    ):
        self.client_id = client_id
        self.client_secret = client_secret

        super().__init__(base_url=base_url, verify=verify, proxy=proxy, **kwargs)

    def http_request(
        self,
        method: str,
        url_suffix: str,
        data: dict | None = None,
        params: dict[str, Any] | None = None,
        pdf: bool = False,
        csv: bool = False,
    ) -> dict[str, Any] | Response | list[dict[str, Any]]:
        """
        Overrides Base client request function, retrieves and adds to headers access token before sending the request.
        """
        token = self.get_access_token()
        headers = {
            "Authorization": f"Bearer {token}",
            "Accept": "application/json",
            "Content-Type": ("application/json" if not pdf else "application/pdf, application/json"),
        }

        demisto.debug(f"Running http-request with URL {url_suffix} and {params=}")

        response = self._http_request(
            method,
            url_suffix=url_suffix,
            headers=headers,
            params=params,
            json_data=data,
            resp_type="response",
            ok_codes=(401, 403, 200, 201, 302, 404),
        )
        if response.status_code in (200, 201):
            return response.json() if not pdf else response

        if response.status_code == 302 and csv:
            cve_response = requests.get(url=response.text)
            return cve_response

        else:
            demisto.debug("Access token has expired, retrieving new access token")

        token = self.get_access_token(create_new_token=True)
        headers["Authorization"] = f"Bearer {token}"

        return self._http_request(
            method,
            url_suffix=url_suffix,
            headers=headers,
            params=params,
            json_data=data,
            ok_codes=(200, 201, 302, 404),
        )

    def get_reports(
        self,
        start_date: str,
        end_date: str,
        limit: int,
    ) -> List[dict[str, Any]]:
        """
        Get manual reports from Cybel Angel Collector.
        The order of the events returned is random, hence need to sort them out to return the oldest events first.

        Args:
            start_date       (str): DATE_FORMAT lower bound (e.g. "2025-05-01T00:00:00.000Z").
            end_date         (str): DATE_FORMAT upper bound (e.g. "2025-05-11T14:00:00.000Z").

        Returns:
            List[dict[str, Any]]: A list of reports events, each containing:
            - all original API fields
            - `_time` set to the value of `REPORT.time_field`
            - `source_log_type` = `REPORT.source_log_type`

            The list is returned in ascending order by detection date.
        """

        params = {"start-date": start_date, "end-date": end_date}
        reports = self.get_reports_list(params)

        demisto.debug(f"Get reports list returned {len(reports)} reports.")
        reports = add_fields_to_events(reports, REPORT)
        reports = sorted(
            reports,
            key=lambda _report: dateparser.parse(_report["_time"]),  # type: ignore[arg-type, return-value]
        )
        return reports

    def get_credentials_watchlist(
        self,
        start_date: str,
        end_date: str,
        limit: int,
    ) -> List[dict[str, Any]]:
        """
        Fetch credential-watchlist events from CybelAngel and prepare them for ingestion into XSIAM.
        This will request up to `limit` credentials ordered by `last_detection_date` ascending,
        then tag each record with `_time` and `SOURCE_LOG_TYPE`.

        Args:
            start_date       (str): DATE_FORMAT lower bound (e.g. "2025-05-01T00:00:00.000Z").
            end_date         (str): DATE_FORMAT upper bound (e.g. "2025-05-11T14:00:00.000Z").
            limit            (int): Maximum number of credential entries to retrieve (default: DEFAULT_MAX_FETCH_CREDS).

        Returns:
            List[dict[str, Any]]: A list of credential-watchlist events, each containing:
            - all original API fields
            - `_time` set to the value of `CREDENTIALS.time_field`
            - `source_log_type` = `CREDENTIALS.source_log_type`

            The list is returned in ascending order by detection date.
        """
        params = {
            "sort_by": "last_detection_date",
            "limit": limit,
            "order": "asc",
            "start": start_date,
            "end": end_date,
        }
        response = self.http_request(method="GET", url_suffix=CREDENTIALS.url_suffix, params=params) or []

        return add_fields_to_events(response, CREDENTIALS)  # type: ignore

    def get_domain_watchlist(
        self,
        start_date: str,
        end_date: str,
        limit: int,
    ) -> List[dict[str, Any]]:
        """
        Fetch domain-watchlist events from CybelAngel, handling pagination when more than `limit` events exist.
        API return in descending order, in order the fetch the oldeset we fetch the whole time interval first.

        CybelAngel's API returns events in descending order by detection date.
        In order the fetch the first 'limit' events we do as follow:
        1. Requests up to `limit` events.
        2. If the API reports more events exists, requests the remaining events using `skip`/`limit`.
        3. Combines both pages.
        4. Annotates each record with `_time` and `SOURCE_LOG_TYPE`.

        Args:
            start_date       (str): DATE_FORMAT lower bound (e.g. "2025-05-01T00:00:00.000Z").
            end_date         (str): DATE_FORMAT upper bound (e.g. "2025-05-11T14:00:00.000Z").
            limit      (int): Maximum number of domain entries to retrieve (default: DEFAULT_MAX_FETCH_DOMAINS).

        Returns:
            List[dict[str, Any]]: Domain-watchlist events sorted in ascending order by
            detection date, each containing:
            - all original API fields
            - `_time` set to the value of `DOMAIN.time_field`
            - `source_log_type` = `DOMAIN.source_log_type`
        """
        params = {
            "min-date": start_date,
            "max-date": end_date,
            "limit": limit,
        }

        response = self.http_request(method="GET", url_suffix=DOMAIN.url_suffix, params=params) or {}

        events = response.get("results", {})  # type:ignore
        total = response.get("total", 0)  # type:ignore
        demisto.debug(f"Fetched {len(events)} / {total} domain events on first call")

        if total > len(events):
            remaining = total - len(events)
            demisto.debug(f"{remaining} more events available; fetching skip={len(events)}")
            params.update({"limit": remaining, "skip": len(events)})
            second_response = self.http_request(method="GET", url_suffix=DOMAIN.url_suffix, params=params) or {}
            events.extend(second_response.get("results", []))  # type: ignore

        demisto.debug(f"Total fetched {len(events)} domain events.")

        return add_fields_to_events(events, DOMAIN)

    def get_access_token(self, create_new_token: bool = False) -> str:
        """
        Obtains access and refresh token from CybleAngel server.
        Access token is used and stored in the integration context until expiration time.
        After expiration, new refresh token and access token are obtained and stored in the
        integration context.

         Returns:
             str: the access token.
        """
        integration_context = get_integration_context()
        current_access_token = integration_context.get("access_token")
        if current_access_token and not create_new_token:
            return current_access_token
        new_access_token = self.get_token_request()
        integration_context = {
            "access_token": new_access_token,
        }
        demisto.debug(f"updating access token at {datetime.now()}")
        set_integration_context(context=integration_context)
        return new_access_token

    def get_token_request(self) -> str:
        """
        Sends request to retrieve token.

        Returns:
           tuple[str, str]: token and its expiration date
        """
        url = "https://auth.cybelangel.com/oauth/token"

        token_response = self._http_request(
            "POST",
            full_url=url,
            json_data={
                "client_id": self.client_id,
                "client_secret": self.client_secret,
                "audience": "https://platform.cybelangel.com/",
                "grant_type": "client_credentials",
            },
        )
        if access_token := token_response.get("access_token"):
            return access_token
        raise RuntimeError(f"Could not retrieve token from {url}, access-token returned is empty")

    def get_reports_list(self, params: dict) -> list:
        """
        Retrieves a list of reports from the CybelAngel API.

        Args:
            params (dict): required parameters to filter the reports.
            start_date (str) and end_date (str)

        Returns:
            List: List of reports, or an empty list if no reports are found.
        """
        return self.http_request(method="GET", url_suffix=REPORT.url_suffix, params=params).get("reports") or []  # type: ignore

    def get_report_by_id(self, id: str, pdf: bool) -> dict[str, Any] | Response | list[dict[str, Any]]:
        """
        Retrieves a report by its ID.

        Args:
            id (str): The ID of the report.
            pdf (bool): Whether to retrieve the report in PDF format.

        Returns:
            dict[str, Any] | Response: The report data or response object.
        """
        endpoint = f"/api/v1/reports/{id}"
        endpoint += "/pdf" if pdf else ""
        return self.http_request("GET", endpoint, pdf=pdf)

    def get_mirror_report(self, id: str, csv: bool) -> dict[str, Any] | Response | list[dict[str, Any]]:
        """
        Retrieves a mirrored report by its ID.

        Args:
            id (str): The ID of the report.
            csv (bool): Whether to retrieve the report in CSV format.

        Returns:
            dict[str, Any] | Response: The mirrored report data or response object.
        """
        endpoint = f"/api/v1/reports/{id}/mirror"
        endpoint += "/csv" if csv else ""
        return self.http_request("GET", endpoint, csv=True)

    def get_archive_report(self, id: str) -> dict[str, Any] | Response | list[dict[str, Any]]:
        """
        Retrieves an archived mirrored report by its ID.

        Args:
            id (str): The ID of the report.

        Returns:
            dict[str, Any] | Response: The archived report data or response object.
        """
        endpoint = f"/api/v1/reports/{id}/mirror/archive"
        return self.http_request("GET", endpoint, csv=True)

    def status_update(self, reports_ids: list, status: str):
        """
        Updates the status of multiple reports.

        Args:
            reports_ids (list): A list of report IDs to update.
            status (str): The new status to apply.

        Returns:
            dict[str, Any] | Response: The response from the API.
        """
        data = {"ids": reports_ids, "status": status}
        return self.http_request("POST", "/api/v1/reports/status", data=data)

    def get_report_comment(self, id: str, data: dict = {}) -> dict[str, Any] | Response | list[dict[str, Any]]:
        """
        Retrieves or creates a comment for a report.

        Args:
            id (str): The ID of the report.
            data (dict, optional): The comment data to create. Defaults to an empty dictionary.

        Returns:
            dict[str, Any] | Response: The report comment data or response object.
        """
        # Using POST method to create new comment
        method = "POST" if data else "GET"
        return self.http_request(method, f"/api/v1/reports/{id}/comments", data=data)

    def get_report_attachment(self, report_id: str, attachment_id: str) -> dict[str, Any] | Response | list[dict[str, Any]]:
        """
        Retrieves an attachment from a report.

        Args:
            report_id (str): The ID of the report.
            attachment_id (str): The ID of the attachment.

        Returns:
            dict[str, Any] | Response: The report attachment data or response object.
        """
        return self.http_request("GET", f"/api/v1/reports/{report_id}/attachments/{attachment_id}", pdf=True)

    def post_report_remediation_request(self, data: dict) -> dict[str, Any] | Response | list[dict[str, Any]]:
        """
        Submits a remediation request for a report.

        Args:
            data (dict): The remediation request data.

        Returns:
            dict[str, Any] | Response: The response from the API.
        """
        return self.http_request("POST", "/api/v1/reports/remediation-request", data=data)


def add_fields_to_events(events: List[Dict[str, Any]], event_type: EventType) -> List[Dict[str, Any]]:
    """
    Annotate each event with:
      - `_time`: from its configured time_fields.
      - `SOURCE_LOG_TYPE`: the event type name.
    """
    for event in events:
        if event_type.name == REPORT.name:
            if updated_at := event.get("updated_at"):
                _time_field = updated_at
            else:
                _time_field = event["created_at"]
        else:
            _time_field = event.get(event_type.time_field)
        event["_time"] = _time_field
        event["SOURCE_LOG_TYPE"] = event_type.source_log_type
    return events


def dedup_fetched_events(events: List[dict], last_run_fetched_event_ids: Set[str], event_type: EventType) -> List[dict]:
    """
    Deduplicate fetch results by filtering out events that have already been processed.

    Args:
        events (List[dict]): A list of event dictionaries as returned by the API.
        last_run_fetched_event_ids (Set[str]): A set of event IDs that were fetched in the previous run.
        event_type (EventType): The event type we are working one (e.g., REPORT, CREDENTIALS, DOMAIN).

    Returns:
        List[dict]: A list of event after deduplication.
    """
    un_fetched_events = []

    for event in events:
        event_id = event_type.get_id(event)
        if event_id not in last_run_fetched_event_ids:
            demisto.debug(f"event with ID {event_id} has not been fetched.")
            un_fetched_events.append(event)
        else:
            demisto.debug(f"event with ID {event_id} for has been fetched")

    return un_fetched_events


def get_latest_event_time_and_ids(
    events: List[Dict[str, Any]],
    event_type: EventType,
    last_run_time: str,
    last_run_ids: list[str],
) -> tuple[str, List[str]]:
    """
    Determine the latest event timestamp and assemble the corresponding IDs.
    This function assumes that `events` is sorted by `_time` in ascending/descending order depend on the type.

    Args:
        reports     (List[Dict]): A list of event dicts, each containing an `_time` key and the relevant ID field.
        event_type   (EventType): The event type.
        last_run_time      (str): The timestamp string recorded in the previous run.
        last_run_ids (List[str]): The list of IDs recorded in the previous run.

    Returns:
        tuple[str, List[str]]: A tuple where:
            - latest_time: the `_time` string of the most recent event
            - latest_ids: list of event IDs for all events whose `_time` matches that latest timestamp.
    """

    latest_time = events[event_type.max_index]["_time"]

    latest_ids = [event_type.get_id(event) for event in events if event["_time"] == latest_time]

    if latest_time == last_run_time:
        latest_ids.extend(last_run_ids)

    return latest_time, latest_ids


def test_module(client: Client, events_type_to_fetch: list[EventType]) -> str:
    """
    Tests that the authentication to the api is ok.
    """
    start_time = (datetime.now() - timedelta(minutes=30)).strftime(DATE_FORMAT)
    end_time = datetime.now().strftime(DATE_FORMAT)
    event_fetch_function = {
        DOMAIN.name: client.get_domain_watchlist,
        CREDENTIALS.name: client.get_credentials_watchlist,
        REPORT.name: client.get_reports,
    }
    for event_type in events_type_to_fetch:
        event_fetch_function[event_type.name](start_date=start_time, end_date=end_time, limit=1)

    return "ok"


def fetch_events(client: Client, events_type_to_fetch: list[EventType]) -> tuple[List[dict[str, Any]], dict[str, Any]]:
    """
    Fetch and deduplicate events across multiple types from CybelAngel, updating last-run state.

    For each event type, this function:
      1. Retrieves the previous run's timestamp and IDs via `get_last_run()`.
      2. Fetch events for each type using the relevant command.
      3. Removes any events already fetched.
      4. If no new events remain:
         - Sets this type's last-run timestamp to now and clears its ID list.
      5. Otherwise:
         - Truncates to `event_type.max_fetch` events.
         - Determines the latest timestamp and corresponding IDs via `get_latest_event_time_and_ids()`.
         - Updates this type's last-run record.
    All newly fetched events (across all types) are concatenated and returned alongside the updated last-run map.

    Args:
        client                  (Client): CybelAngel API client instance.
        event_types_to_fetch (List[EventType]): List of event-type keys to fetch (REPORT, DOMAIN, CREDENTIALS).

    Returns:
        Tuple[List[Dict[str, Any]], Dict[str, Dict[str, Any]]]:
            - all_events: List of deduplicated event dicts fetched across all types.
            - last_run: Mapping from each event type to its updated:
                {
                  LATEST_TIME: "<ISO timestamp>",
                  LATEST_FETCHED_IDS: [<str>, ...]
                }
    """
    now = datetime.now()
    last_run = get_last_run(now, events_type_to_fetch)
    all_events = []

    event_fetch_function = {
        DOMAIN.name: client.get_domain_watchlist,
        CREDENTIALS.name: client.get_credentials_watchlist,
        REPORT.name: client.get_reports,
    }

    for event_type in events_type_to_fetch:
        demisto.debug(f"Fetching {event_type.name}")

        last_time = last_run[event_type.name][LATEST_TIME]
        last_ids = last_run[event_type.name][LATEST_FETCHED_IDS]
        demisto.debug(f"Last run for {event_type.name}: time={last_time}, ids={len(last_ids)}")

        events = event_fetch_function[event_type.name](
            start_date=last_time,
            end_date=now.strftime(DATE_FORMAT),
            limit=event_type.max_fetch + len(last_ids),
        )
        demisto.debug(f"Fetched {len(events)} raw events for {event_type.name}")

        events = dedup_fetched_events(events=events, last_run_fetched_event_ids=set(last_ids), event_type=event_type)
        demisto.debug(f"{len(events)} events remain after dedup for {event_type.name}")

        if events:
            events = events[: event_type.max_fetch] if event_type.ascending_order else events[-event_type.max_fetch :]
            latest_time, latest_ids = get_latest_event_time_and_ids(events, event_type, last_time, last_ids)
            demisto.debug(f"{event_type.name} latest time: {latest_time}, latest IDs: {latest_ids}")

            last_run[event_type.name] = {LATEST_TIME: normalize_date_format(latest_time), LATEST_FETCHED_IDS: latest_ids}
            all_events.extend(events)

        else:
            demisto.debug(f"No new {event_type.name} events; resetting last_run timestamp")
            last_run[event_type.name] = {LATEST_TIME: now.strftime(DATE_FORMAT), LATEST_FETCHED_IDS: []}

    demisto.debug(f"Total events fetched across all types: {len(all_events)}")
    return all_events, last_run


def get_events_command(client: Client, args: dict[str, Any]) -> CommandResults:
    """
    Get events from Cybel Angel, used mainly for debugging purposes
    """
    event_type = EVENT_TYPE.get(args.get("events_type_to_fetch", "")) or REPORT
    limit = int(args.get("limit", 50))

    now = datetime.now()
    end_date = args.get("end_date") or now.strftime(DATE_FORMAT)
    end_dt = dateparser.parse(end_date) or now
    start_date = args.get("start_date") or (end_dt - timedelta(minutes=1)).strftime(DATE_FORMAT)

    events = []

    event_fetch_function = {
        DOMAIN.name: client.get_domain_watchlist,
        CREDENTIALS.name: client.get_credentials_watchlist,
        REPORT.name: client.get_reports,
    }

    events = event_fetch_function[event_type.name](start_date=start_date, end_date=end_date, limit=limit)
    events = events[:limit]
    if argToBoolean(args.get("should_push_events") or False):
        send_events_to_xsiam(vendor=VENDOR, product=PRODUCT, events=events)
        demisto.debug(f"Successfully send {len(events)} to XSIAM.")
    return CommandResults(
        readable_output=tableToMarkdown(event_type.name, events, removeNull=False),
    )


def cybelangel_report_list_command(client: Client, args: dict) -> CommandResults:
    """
    Retrieves a list of reports within the specified date range.

    Args:
        client (Client): CybelAngel API client.
        args (dict): Includes optional `start_date` and `end_date`.

    Returns:
        CommandResults: Report list in table format.
    """
    start_date = arg_to_datetime(args.get("start_date"))
    end_date = arg_to_datetime(args.get("end_date"))

    response = client.get_reports_list({"start-date": start_date, "end-date": end_date})
    human_readable = tableToMarkdown(
        "Reports list",
        response,
        headers=[
            "id",
            "url",
            "report_type",
            "sender",
            "severity",
            "status",
            "updated_at",
            "report_content",
        ],
    )
    return CommandResults(
        outputs_prefix="CybelAngel.Report",
        outputs_key_field="id",
        outputs=response,
        readable_output=human_readable,
    )


def cybelangel_report_get_command(client: Client, args: dict) -> CommandResults | dict:
    """
    Retrieves a report by ID, optionally as a PDF.

    Args:
        client (Client): CybelAngel API client.
        args (dict): Includes `report_id` (required) and `pdf` (optional).

    Returns:
        CommandResults: Report details, or a PDF file result.
    """
    report_id = args.get("report_id", "")
    pdf = argToBoolean(args.get("pdf", "false"))
    response = client.get_report_by_id(report_id, pdf=pdf)
    if pdf:
        return fileResult(f"cybelangel_report_{report_id}.pdf", response.content, EntryType.ENTRY_INFO_FILE)  # type: ignore
    human_readable = tableToMarkdown(
        f"Report ID {report_id} details",
        response,
        headers=[
            "id",
            "url",
            "report_type",
            "sender",
            "severity",
            "status",
            "updated_at",
            "report_content",
        ],
        removeNull=True,
    )
    return CommandResults(
        outputs_prefix="CybelAngel.Report",
        outputs_key_field="id",
        outputs=response,
        readable_output=human_readable,
    )


def cybelangel_mirror_report_get_command(client: Client, args: dict) -> CommandResults | dict:
    """
    Retrieves mirror details for a report, optionally as a CSV file.

    Args:
        client (Client): CybelAngel API client.
        args (dict): Includes `report_id` (required) and `csv` (optional).

    Returns:
        CommandResults: Mirror report details or a CSV file result.
    """
    report_id = args.get("report_id", "")
    csv = argToBoolean(args.get("csv", "false"))
    response = client.get_mirror_report(report_id, csv)

    if isinstance(response, dict) and "title" in response:
        return CommandResults(raw_response=response, readable_output=f"{response.get('title')}")

    if csv:
        return fileResult(
            f"cybelangel_mirror_report_{report_id}.csv",
            response.content,  # type: ignore
            file_type=EntryType.ENTRY_INFO_FILE,
        )
    human_readable = tableToMarkdown(
        f"Mirror details for Report ID {report_id}",
        response,
        headers=["report_id", "created_at", "available_files_count", "updated_at"],
        removeNull=True,
    )
    return CommandResults(
        outputs_prefix="CybelAngel.ReportMirror",
        outputs_key_field="report_id",
        outputs=response,
        readable_output=human_readable,
    )


def cybelangel_archive_report_by_id_get_command(client: Client, args: dict) -> CommandResults | dict:
    """
    Retrieves the archived mirror of a report as a ZIP file.

    Args:
        client (Client): CybelAngel API client.
        args (dict): Includes `report_id` (required).

    Returns:
        CommandResults: Archive report as a ZIP file or a raw response with the report title.
    """
    report_id = args.get("report_id", "")

    response = client.get_archive_report(report_id)
    if isinstance(response, dict) and "title" in response:
        return CommandResults(raw_response=response, readable_output=f"{response.get('title')}")

    return fileResult(
        f"cybelangel_archive_report_{report_id}.zip",
        response.content,  # type: ignore
        file_type=EntryType.ENTRY_INFO_FILE,
    )


def cybelangel_report_status_update_command(client: Client, args: dict) -> CommandResults:  # pragma: no cover
    """
    Updates the status of one or more reports.

    Args:
        client (Client): CybelAngel API client.
        args (dict): Includes `report_ids` (required) and `status` (required).

    Returns:
        CommandResults: Success message with the count of updated reports.
    """
    report_ids = argToList(args.get("report_ids"))
    status = args.get("status", "")

    client.status_update(report_ids, status)

    return CommandResults(
        readable_output=f"The status of the following reports {report_ids} has been successfully updated to {status}"
    )


def cybelangel_report_comments_get_command(client: Client, args: dict) -> CommandResults:  # pragma: no cover
    """
    Retrieves comments for a specific report by its ID.

    Args:
        client (Client): CybelAngel API client.
        args (dict): Includes `report_id` (required).

    Returns:
        CommandResults: Comments related to the report in a structured table format.
    """
    report_id = args.get("report_id", "")

    response = client.get_report_comment(report_id)

    if not response.get("comments"):  # type: ignore
        return CommandResults(readable_output=f"There are no comments for report ID: {report_id}")
    if isinstance(response, dict):
        response["id"] = report_id
        response["Comment"] = response.pop("comments")
    hr_response = [
        {**comment, "author_firstname": comment["author"]["firstname"], "author_lastname": comment["author"]["lastname"]}
        for comment in response.get("Comment", [])  # type: ignore
    ]
    human_readable = tableToMarkdown(
        f"Comments for Report ID {report_id}",
        hr_response,
        headers=[
            "content",
            "created_at",
            "parent_id",
            "discussion_id",
            "assigned",
            "author_firstname",
            "author_lastname",
            "last_updated_at",
        ],
        removeNull=True,
    )
    return CommandResults(
        outputs_prefix="CybelAngel.Report",
        outputs_key_field="id",
        outputs=response,
        readable_output=human_readable,
    )


def cybelangel_report_comment_create_command(client: Client, args: dict) -> CommandResults:
    """
    Adds a comment to a specific report.

    Args:
        client (Client): CybelAngel API client.
        args (dict): Includes `report_id` (required), `content` (required), `parent_id` (optional), and `assigned` (optional).

    Returns:
        CommandResults: Success message indicating comment creation.
    """
    discussion_id = args.get("discussion_id", "")
    if ":" not in discussion_id:
        raise ValueError("Invalid discussion_id format. Expected format: 'report_id:tenant_id'.")
    report_id = discussion_id.split(":")[0]
    content = args.get("content")
    parent_id = args.get("parent_id")
    assigned = argToBoolean(args.get("assigned", "false"))

    data = {"content": content, "discussion_id": discussion_id}
    if parent_id:
        data["parent_id"] = parent_id
    if assigned:
        data["assigned"] = assigned

    client.get_report_comment(report_id, data=data)

    return CommandResults(
        readable_output=f"Comment created successfully for report ID: {report_id}",
    )


def cybelangel_report_attachment_get_command(client: Client, args: dict) -> dict | CommandResults:
    """
    Retrieves a specific attachment from a report.

    Args:
        client (Client): CybelAngel API client.
        args (dict): Includes `report_id` (required) and `attachment_id` (required).

    Returns:
        dict | CommandResults: Attachment content as a file or raw response with attachment title.
    """
    report_id = args.get("report_id", "")
    attachment_id = args.get("attachment_id", "")

    response = client.get_report_attachment(report_id, attachment_id)
    if isinstance(response, dict) and "title" in response:
        return CommandResults(raw_response=response, readable_output=f"{response.get('title')}")

    return fileResult(
        f"cybelangel_report_{report_id}_attachment_{attachment_id}.csv",
        response.text,  # type: ignore
        file_type=EntryType.ENTRY_INFO_FILE,
    )


def cybelangel_report_remediation_request_create_command(client: Client, args: dict) -> CommandResults:  # pragma: no cover
    """
    Creates a remediation request for a report.

    Args:
        client (Client): CybelAngel API client.
        args (dict): Includes `report_id`, `requestor_email`, and `requestor_fullname` (all required).

    Returns:
        CommandResults: Success message indicating the remediation request creation.
    """
    report_id = args.get("report_id")
    requestor_email = args.get("requestor_email")
    requestor_fullname = args.get("requestor_fullname")

    data = {
        "report_id": report_id,
        "requester_email": requestor_email,
        "requester_fullname": requestor_fullname,
    }

    response = client.post_report_remediation_request(data)

    return CommandResults(
        outputs_prefix="CybelAngel.RemediationRequest",
        outputs_key_field="report_id",
        outputs=response,
        readable_output=f"Remediation request was created for {report_id}",
    )


def get_last_run(now: datetime, events_type_to_fetch: list[EventType]) -> dict[str, Any]:
    """
    Retrieve and initialize the “last run” timestamps for a set of event types.
    This function loads the existing last‐run state via `demisto.getLastRun()`.
    For any event type that is missing or newly requested, it sets:
      - `LATEST_TIME` to one minute before `now`.
      - `LATEST_FETCHED_IDS` to an empty list

    Args:
        now (datetime): Reference time for computing initial fetch timestamps.
        events_to_fetch (List[str]): Names of event types that should be tracked this run.

    Returns:
        Dict[str, Dict[str, Any]]: A mapping of each event type to its last-run info:
            {
                "<EVENT_TYPE>": {
                    LATEST_TIME: String date formatted as DATE_FORMAT,
                    LATEST_FETCHED_IDS: List[str]
                },
            }
    """
    last_run = demisto.getLastRun()
    last_time = now - timedelta(minutes=1)
    if not last_run:
        last_run = {}
        demisto.debug("First run")
    for event_type in [REPORT, DOMAIN, CREDENTIALS]:
        if event_type.name not in last_run or (event_type.name in last_run and event_type not in events_type_to_fetch):
            last_run[event_type.name] = {
                LATEST_TIME: last_time.strftime(DATE_FORMAT),
                LATEST_FETCHED_IDS: [],
            }

    return last_run


def normalize_date_format(date_string: str) -> str:
    """
    Normalizes a date string to a consistent UTC format ending with 'Z'.

    This function handles three specific input formats:
    1. 2025-05-18T06:10:37Z (already in a valid format)
    2. 2024-08-14T08:48:51.380211 (missing timezone)
    3. 2020-05-15T12:30:25+00:00 (UTC offset format)

    Args:
        date_string: The input date string.

    Returns:
        The normalized date string ending with 'Z'.
    """
    if date_string.endswith("+00:00"):
        return date_string[:-6] + "Z"

    if date_string.endswith("Z"):
        return date_string

    return date_string + "Z"


def set_event_type_fetch_limit(params: dict[str, Any]) -> list[EventType]:
    """
    Parses the event types to fetch from parameters and returns a dictionary mapping
    each selected event type's suffix to its corresponding max fetch limit.

    Args:
        params (Dict[str, Any]): Integration parameters.

    Returns:
        list[EventType]: List of event type to fetch from the api call.
    """
    event_type_names = [et.strip() for et in argToList(params.get("event_types_to_fetch", [REPORT.name]))]
    demisto.debug(f"List:{event_type_names}, list length:{len(event_type_names)}")
    fetch_limits = {
        REPORT.name: arg_to_number(params.get("max_fetch")) or REPORT.default_max_fetch,
        CREDENTIALS.name: arg_to_number(params.get("max_fetch_creds")) or CREDENTIALS.default_max_fetch,
        DOMAIN.name: arg_to_number(params.get("max_fetch_domain")) or DOMAIN.default_max_fetch,
    }
    event_types = []
    for event_type in EVENT_TYPE.values():
        if event_type.name in event_type_names:
            event_type.max_fetch = fetch_limits[event_type.name]
            event_types.append(event_type)

    return event_types


""" MAIN FUNCTION """


def main() -> None:  # pragma: no cover
    params = demisto.params()
    args = demisto.args()
    client_id: str = params.get("credentials", {}).get("identifier", "")
    client_secret: str = params.get("credentials", {}).get("password", "")
    base_url: str = params.get("url", "").rstrip("/")
    verify_certificate = not params.get("insecure", False)
    proxy = params.get("proxy", False)

    commands = {
        "cybelangel-report-list": cybelangel_report_list_command,
        "cybelangel-report-get": cybelangel_report_get_command,
        "cybelangel-mirror-report-get": cybelangel_mirror_report_get_command,
        "cybelangel-archive-report-by-id-get": cybelangel_archive_report_by_id_get_command,
        "cybelangel-report-status-update": cybelangel_report_status_update_command,
        "cybelangel-report-comments-get": cybelangel_report_comments_get_command,
        "cybelangel-report-comment-create": cybelangel_report_comment_create_command,
        "cybelangel-report-attachment-get": cybelangel_report_attachment_get_command,
        "cybelangel-report-remediation-request-create": cybelangel_report_remediation_request_create_command,
    }

    command = demisto.command()
    demisto.info(f"Command being called is {command}")
    try:
        client = Client(
            client_id=client_id, client_secret=client_secret, base_url=base_url, verify=verify_certificate, proxy=proxy
        )
        if command == "test-module":
            return_results(test_module(client, set_event_type_fetch_limit(params)))
        elif command == "fetch-events":
            events, last_run = fetch_events(client, set_event_type_fetch_limit(params))
            send_events_to_xsiam(events, vendor=VENDOR, product=PRODUCT)
            demisto.debug(f'Successfully sent event {[event.get("id") for event in events]} IDs to XSIAM')
            demisto.setLastRun(last_run)
        elif command == "cybelangel-get-events":
            return_results(get_events_command(client, args))
        elif command in commands:
            return_results(commands[command](client, args))
    except Exception as e:
        demisto.error(traceback.format_exc())
        return_error(f"Failed to execute {command} command.\nError:\ntype:{type(e)}, error:{str(e)}")


""" ENTRY POINT """


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