RecordedFutureAlerts

Fetch and triage alerts from Recorded Future.

Data Enrichment & Threat Intelligence · Recorded Future

Details

IDRecordedFutureAlerts
ProviderMastercard
CategoryData Enrichment & Threat Intelligence
From Version6.1.0
Docker Imagedemisto/python3:3.12.13.10116658
Supported ModulesAgentix XSIAM

README

Recorded Future Alerts

Fetch and triage Recorded Future Classic Alerts and Recorded Future Playbook Alerts directly from Cortex
XSOAR.

The integration allows you to:

  • Search and fetch alerts from the Recorded Future platform.
  • Look up full alert details for a single Classic Alert or Playbook Alert by ID.
  • Update alert status, assignee and comment/note from inside XSOAR.
  • Automatically fetch screenshots that accompany the alert.

Migration from Recorded Future Intelligence pack

Guide: migrating from Recorded Future Intelligence pack

Configure Recorded Future Alerts in Cortex

Parameter Description Required
Fetch incidents Turn on incident fetching. When enabled, the integration will poll Recorded Future at the defined interval and create / update incidents for new or updated alerts. False
Incident type Incident type will be set by this field if a Classifier does not exist. If a Classifier is selected, it will take precedence. Leave empty to let the built-in classifier decide based on the alert subtype. False
Your server URL Base URL for the Recorded Future XSOAR gateway API. The default value https://api.recordedfuture.com/gw/xsoar/. True
API Key Recorded Future user API token used to authenticate the requests. True
Source Reliability How trustworthy Recorded Future should be considered when the integration sets DBot scores. False
Incidents fetch interval How often to poll for new alerts. False
Maximum number of incidents per fetch Hard cap on the number of alerts to pull in a single fetch cycle (maximum 50, due to API limits). False
First fetch time How far back to look on the very first fetch run. Maximum look-back is 90 days. False
Enable Classic Alerts Toggle fetching of Classic Alerts. Disable if you only need Playbook Alerts. False
Classic Alerts: Rule names to fetch Semicolon-separated Classic Alert rule names to include (e.g., Malware;Typosquat). Leave blank to fetch alerts from all rules. False
Classic Alerts: Statuses to fetch Classic Alert statuses to be fetched. Choose one or more of New, InProgress, Resolved, Dismissed. True
Enable Playbook Alerts Toggle fetching of Playbook Alerts. Disable if you only need Classic Alerts. False
Playbook Alerts: Priority to fetch Minimum priority threshold. Alerts with lower priority than selected value will not be fetched. Possible values: Informational, Moderate, High. False
Playbook Alerts: Categories to fetch Comma-separated list of Playbook Alert categories to include (e.g., domain_abuse,cyber_vulnerability). Leave blank to fetch all categories available to your licence. False
Playbook Alerts: Statuses to fetch Playbook Alert statuses to be fetched (choose one or more of New, InProgress, Resolved, Dismissed). True
Trust any certificate (not secure) Skip TLS certificate validation. For example, enable this when using a proxy that re-signs SSL traffic or for testing with self-signed certs. False
Use system proxy settings Route all HTTP/S requests through the system-wide proxy settings configured in Cortex XSOAR. 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.

rf-alerts


List Classic or Playbook alerts.

Base Command

rf-alerts

Input

Argument Name Description Required
include_classic_alerts Whether classic alerts should be included in the response. Possible values are: true, false. Default is true. Optional
include_playbook_alerts Whether playbook alerts should be included in the response. Possible values are: true, false. Default is true. Optional
classic_alert_rule_ids Comma-separated Classic Alert Rule IDs. Only applied to Classic Alert search. Optional
playbook_alert_categories Comma-separated Playbook Alert categories. Only applied to Playbook Alert search. Possible values are: domain_abuse, cyber_vulnerability, code_repo_leakage, third_party_risk, geopolitics_facility. Optional
playbook_alert_priorities Comma-separated Playbook Alert priorities. Only applied to Playbook Alert search. Possible values are: Informational, Moderate, High. Optional
statuses Comma-separated list of statuses to include. Possible values are: New, InProgress, Resolved, Dismissed. Optional
limit Maximum number of alerts to return. Maximum allowed value is 50. Default is 10. Optional
order_by Field to sort by. Possible values are: created_at, updated_at. Default value is updated_at. Optional
order_direction Direction to sort by. Possible values are: asc, desc. Default value is desc. Optional
created_from Return only alerts created on or after this datetime (ex. “2025-05-17T16:06:00Z”). Optional
created_to Return only alerts created on or before this datetime (ex. “2025-05-17T16:06:00Z”). Optional
updated_from Return only alerts updated on or after this datetime (ex. “2025-05-17T16:06:00Z”). Optional
updated_to Return only alerts updated on or before this datetime (ex. “2025-05-17T16:06:00Z”). Optional

Context Output

Path Type Description
RecordedFutureAlerts.Alert.id string Unique id of the alert in Recorded Future.
RecordedFutureAlerts.Alert.title string Title of the alert.
RecordedFutureAlerts.Alert.type string Alert type (classic-alert / playbook-alert).
RecordedFutureAlerts.Alert.subtype string Alert subtype (domain_abuse / cyber_vulnerability / code_repo_leakage / third_party_risk / geopolitics_facility / classic-alert).
RecordedFutureAlerts.Alert.status string Status of the alert.
RecordedFutureAlerts.Alert.created string When the alert was created as an ISO8601 string.
RecordedFutureAlerts.Alert.updated string When the alert was updated as an ISO8601 string.
RecordedFutureAlerts.Alert.classic_alert_rule_name string If alert is a classic alert, this is the name of the rule that triggered the alert.
RecordedFutureAlerts.Alert.classic_alert_rule_id string If alert is a classic alert, this is the name of the rule that triggered the alert.
RecordedFutureAlerts.Alert.playbook_alert_category string If alert is a playbook alert, this is the category of the alert.
RecordedFutureAlerts.Alert.playbook_alert_priority string If alert is a playbook alert, this is the priority of the alert.

Command Example

!rf-alerts include_classic_alerts=false playbook_alert_categories=domain_abuse playbook_alert_priorities=High statuses=New limit=5 order_by=updated_at order_direction=desc created_from="2025-05-17T12:06:00Z"

Context Example

{
  "RecordedFutureAlerts": {
    "Alert": [
      {
        "id": "task:fc34c790-293b-42bd-8f23-c1f571323f8f",
        "title": "Potential Typosquat of example.com",
        "type": "playbook-alert",
        "subtype": "domain_abuse",
        "status": "New",
        "created": "2025-05-17T16:06:00Z",
        "updated": "2025-05-17T17:14:12Z",
        "playbook_alert_category": "domain_abuse",
        "playbook_alert_priority": "High",
        "classic_alert_rule_name": null,
        "classic_alert_rule_id": null
      },
      {
        "id": "7SKZ26",
        "title": "ClassiAlert",
        "type": "classic-alert",
        "subtype": "classic-alert",
        "status": "New",
        "created": "2025-05-17T15:58:30Z",
        "updated": "2025-05-17T16:40:00Z",
        "classic_alert_rule_name": "Alert rule name 1",
        "classic_alert_rule_id": "fDasdfwea",
        "playbook_alert_category": null,
        "playbook_alert_priority": null
      }
    ]
  }
}

rf-alert-lookup


Look up a single Recorded Future alert by ID.

Base Command

rf-alert-lookup

Input

Argument Name Description Required
alert_id ID of the alert. Required

Context Output

Path Type Description
RecordedFutureAlerts.Alert.id string Unique ID of the alert in Recorded Future.
RecordedFutureAlerts.Alert.title string Title of the alert.
RecordedFutureAlerts.Alert.type string Alert type (classic-alert / playbook-alert).
RecordedFutureAlerts.Alert.subtype string Alert subtype / category.
RecordedFutureAlerts.Alert.status string Current status of the alert.
RecordedFutureAlerts.Alert.created string Timestamp when the alert was created in ISO 8601 format, for example, 2025-05-17T16:06:00Z.
RecordedFutureAlerts.Alert.updated string Timestamp when the alert was last updated in ISO 8601 format, for example, 2025-05-17T16:06:00Z.
RecordedFutureAlerts.Alert.markdown string Markdown-formatted summary of the alert details.
RecordedFutureAlerts.Alert.images Unknown Image IDs attached to the alert.
RecordedFutureAlerts.Alert.classic_alert_rule_name string If alert is a Classic Alert, name of the rule that triggered the alert.
RecordedFutureAlerts.Alert.classic_alert_rule_id string If alert is a Classic Alert, ID of the rule that triggered the alert.
RecordedFutureAlerts.Alert.portal_url string If alert is a Classic Alert, portal URL for the alert.
RecordedFutureAlerts.Alert.ai_insights string If alert is a Classic Alert, AI-generated insight summary.
RecordedFutureAlerts.Alert.review.status string If alert is a Classic Alert, review status.
RecordedFutureAlerts.Alert.review.assignee string If alert is a Classic Alert, assignee.
RecordedFutureAlerts.Alert.review.note string If alert is a Classic Alert, review note.
RecordedFutureAlerts.Alert.entities Unknown If alert is a Classic Alert, entities referenced in the alert.
RecordedFutureAlerts.Alert.fragments Unknown If alert is a Classic Alert, text fragments from the alert hits.
RecordedFutureAlerts.Alert.documents Unknown If alert is a Classic Alert, documents associated with alert hits.
RecordedFutureAlerts.Alert.analyst_notes Unknown If alert is a Classic Alert, analyst-note URLs linked in the alert.
RecordedFutureAlerts.Alert.triggered_by Unknown If alert is a Classic Alert, entities or paths that triggered the alert.
RecordedFutureAlerts.Alert.playbook_alert_category string If alert is a Playbook Alert, category of the alert.
RecordedFutureAlerts.Alert.playbook_alert_priority string If alert is a Playbook Alert, priority of the alert.
RecordedFutureAlerts.Alert.entity_name string If alert is a Playbook Alert, primary entity name.
RecordedFutureAlerts.Alert.targets Unknown If alert is a Playbook Alert, target names associated with the alert.
RecordedFutureAlerts.Alert.log_entries Unknown If alert is a Playbook Alert, alert activity log entries.
RecordedFutureAlerts.Alert.log_entries.created string If alert is a Playbook Alert, timestamp when the log entry was created in ISO 8601 format, for example, 2025-05-17T16:06:00Z.
RecordedFutureAlerts.Alert.log_entries.author_name string If alert is a Playbook Alert, name of the log entry author.
RecordedFutureAlerts.Alert.log_entries.author_id string If alert is a Playbook Alert, ID of the log entry author.
RecordedFutureAlerts.Alert.log_entries.changes Unknown If alert is a Playbook Alert, raw change records from the alert activity log.
RecordedFutureAlerts.Alert.details Unknown If alert is a Playbook Alert, category-specific detailed panel data.

Command Example

!rf-alert-lookup alert_id=task:75ac1730-3811-44be-8ea7-de7916166c10

Context Example

{
  "RecordedFutureAlerts": {
    "Alert": {
      "id": "task:75ac1730-3811-44be-8ea7-de7916166c10",
      "title": "RF Playbook Alert - Vulnerability CVE-2021-44228",
      "type": "playbook-alert",
      "subtype": "cyber_vulnerability",
      "status": "New",
      "created": "2025-05-14T02:31:00Z",
      "updated": "2025-05-14T03:40:10Z",
      "markdown": "## Vulnerability\n\n### Summary\n\n**ID:** task:75ac1730-3811-44be-8ea7-de7916166c10",
      "playbook_alert_category": "cyber_vulnerability",
      "playbook_alert_priority": "Moderate",
      "entity_name": "CVE-2021-44228",
      "log_entries": [
        {
          "created": "2025-05-14T02:36:04Z",
          "author_name": "RF PI test Phantom modules",
          "author_id": "uhash:60ptFTXURB",
          "changes": [
            {
              "type": "status_change",
              "old": "New",
              "new": "Resolved"
            },
            {
              "type": "comment_change",
              "comment": "This has been handled."
            }
          ]
        }
      ],
      "details": {
        "type": "cyber_vulnerability",
        "lifecycle_stage": "Exploitation"
      }
    }
  }
}

rf-alert-update


Update an alert in the Recorded Future platform.

Base Command

rf-alert-update

Input

Argument Name Description Required
alert_id ID of alert to update. Required
status New status to set for the alert. Possible values are: New, InProgress, Dismissed, Resolved. Optional
comment Add comment / Replace note. Optional
reopen Only for Playbook Alerts. Set the reopen strategy for the alert. Reopen on significant updates or keep the alert Resolved. Can only be used with status=Resolved. Possible values are: never, significant_updates. Default: reopen on significant updates. Optional

Context Output

Path Type Description
RecordedFutureAlerts.Alert.id string Unique id of the alert in Recorded Future.
RecordedFutureAlerts.Alert.type string Alert type (classic-alert / playbook-alert).
RecordedFutureAlerts.Alert.status string Status of alert in Recorded Future.
RecordedFutureAlerts.Alert.comment string Note (Classic) or comment (Playbook) that was just applied.

Command Example

!rf-alert-update alert_id=task:fc34c790-293b-42bd-8f23-c1f571323f8f status=Resolved comment="Alert resolved - false-positive." reopen=never

Context Example

{
  "RecordedFutureAlerts": {
    "Alert": {
      "id": "task:fc34c790-293b-42bd-8f23-c1f571323f8f",
      "type": "playbook-alert",
      "status": "Resolved",
      "comment": "Alert resolved - false-positive."
    }
  }
}

rf-alert-rules


Search for alert rule IDs.

Base Command

rf-alert-rules

Input

Argument Name Description Required
rule_name Rule name to search. Can be a partial name. Optional
limit Maximum number of rules to return. Default is 10. Optional

Context Output

Path Type Description
RecordedFutureAlerts.AlertRule.id string Alert rule ID.
RecordedFutureAlerts.AlertRule.name string Alert rule name.

Command Example

!rf-alert-rules rule_name="malware" limit=3

Context Example

{
  "RecordedFutureAlerts": {
    "AlertRule": [
      {
        "id": "mZbDYT",
        "name": "Malware Communication - External IP"
      },
      {
        "id": "mZbDZT",
        "name": "Malware Communication - Suspicious Domain"
      },
      {
        "id": "mxbDZT",
        "name": "Malware Communication - Command & Control"
      }
    ]
  }
}

rf-alert-images


Fetch alert images and attach to incident in context Files.

Base Command

rf-alert-images

Command Example

!rf-alert-images

Input

There are no input arguments for this command.

Context Output

Path Type Description
Files Unknown New images are attached into incident Files.

Configuration parameters

  • isFetch — Fetch incidents
  • incidentType — Incident type
  • url — Your server URL (required)
  • credentials — (required)
  • integrationReliability — Source Reliability
  • incidentFetchInterval — Incidents fetch interval
  • max_fetch — Maximum number of incidents per fetch
  • first_fetch — First fetch time
  • classic_alerts_enabled — Enable Classic Alerts
  • classic_alert_rule_names — Classic Alerts: Rule names to fetch
  • classic_alert_statuses — Classic Alerts: Statuses to fetch (required)
  • playbook_alerts_enabled — Enable Playbook Alerts
  • playbook_alert_priority — Playbook Alerts: Priority to fetch
  • playbook_alert_categories — Playbook Alerts: Categories to fetch
  • playbook_alert_statuses — Playbook Alerts: Statuses to fetch (required)
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings

Commands (5)

  • rf-alert-images

    Fetch alert images.

  • rf-alert-lookup

    Look up a single Recorded Future alert by ID. Supports both Classic Alerts and Playbook Alerts. Returns full alert data including rule details, entities, AI insights, and Playbook Alert panel data.

  • rf-alert-rules

    Search for alert rule IDs.

  • rf-alert-update

    Update an alert in the Recorded Future platform.

  • rf-alerts

    List Classic or Playbook alerts.

import concurrent.futures
import platform
from typing import Any

import urllib3
import demistomock as demisto
from CommonServerPython import *  # noqa # pylint: disable=unused-wildcard-import
from CommonServerUserPython import *  # noqa

# Disable insecure warnings
urllib3.disable_warnings()

""" CONSTANTS """

DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"  # ISO8601 format with UTC, default in XSOAR

MAX_IMAGES_TO_FETCH = 25

STATUS_TO_RETRY = [500, 501, 502, 503, 504]

__version__ = "0.1.0"

TIMEOUT_60 = 60
TIMEOUT_90 = 90
TIMEOUT_120 = 120

""" CLIENT CLASS """


class Client(BaseClient):
    """Client class to interact with the service API"""

    def _request_raw(
        self,
        *,
        method: str,
        url_suffix: str,
        params: dict | None = None,
        json_data: dict | None = None,
        timeout: int = 90,
        retries: int = 3,
    ) -> dict:
        response: Any = self._http_request(
            method=method,
            url_suffix=url_suffix,
            params=params,
            json_data=json_data,
            timeout=timeout,
            retries=retries,
            status_list_to_retry=STATUS_TO_RETRY,
        )
        if not isinstance(response, dict):
            raise DemistoException(f"Bad Response, response was not a dict: {str(response)}")
        if response.get("return_error"):
            return_error(**response["return_error"])
            raise DemistoException("return_error returned unexpectedly")
        return response

    @staticmethod
    def _no_results_found() -> list[CommandResults]:
        return [
            CommandResults(
                outputs_prefix="",
                outputs={},
                raw_response={},
                readable_output="No results found.",
                outputs_key_field="",
            )
        ]

    @classmethod
    def _response_to_command_results(cls, response: dict) -> list[CommandResults]:
        result_actions = response.get("result_actions")
        if not isinstance(result_actions, list):
            raise DemistoException(f"Bad Response, result_actions was present but not a list: {str(response)}")

        command_results: list[CommandResults] = []
        for result_action in result_actions:
            if not isinstance(result_action, dict):
                continue

            raw_response = result_action.get("raw_response")
            outputs = raw_response.get("outputs") if isinstance(raw_response, dict) else None
            command_results.append(
                CommandResults(
                    outputs=outputs,
                    **result_action,
                )
            )

        return command_results

    def _request_results(
        self,
        *,
        method: str,
        url_suffix: str,
        params: dict | None = None,
        json_data: dict | None = None,
        timeout: int = 90,
        retries: int = 3,
    ) -> list[CommandResults]:
        try:
            response = self._request_raw(
                method=method,
                url_suffix=url_suffix,
                params=params,
                json_data=json_data,
                timeout=timeout,
                retries=retries,
            )
        except DemistoException as err:
            if "404" in str(err):
                return self._no_results_found()
            raise

        return self._response_to_command_results(response)

    def whoami(self) -> dict:
        return self._request_raw(
            method="GET",
            url_suffix="/info/whoami",
            timeout=60,
        )

    def alert_update(self) -> list[CommandResults]:
        """Update alert"""
        return self._request_results(
            method="POST",
            url_suffix="/v3/alert/update",
            json_data=demisto.args(),
            timeout=90,
        )

    def alert_search(self) -> list[CommandResults]:
        """Search alerts"""
        return self._request_results(
            method="GET",
            url_suffix="/v3/alert/search",
            params=demisto.args(),
        )

    def alert_rule_search(self) -> list[CommandResults]:
        """Search alert rules."""
        return self._request_results(
            method="GET",
            url_suffix="/v3/alert/rules",
            params=demisto.args(),
        )

    def alert_lookup(self, alert_id: str) -> list[CommandResults]:
        return self._request_results(
            method="GET",
            url_suffix="/v3/alert/lookup",
            params={"alert_id": alert_id},
            timeout=90,
        )

    def get_alert_image(
        self,
        alert_type: str,
        alert_id: str,
        image_id: str,
        alert_subtype: str | None,
    ) -> bytes:
        """
        Get an image from the v3 alert image endpoint.
        Returns the raw binary content of the image.
        """
        response_content: Any = self._http_request(
            method="get",
            url_suffix="/v3/alert/image",
            params={
                "alert_type": alert_type,
                "alert_subtype": alert_subtype,
                "alert_id": alert_id,
                "image_id": image_id,
            },
            timeout=90,
            resp_type="content",
        )
        return response_content

    def fetch_incidents(self) -> dict:
        """Fetch incidents."""
        classic_query_params = demisto.getLastRun().get("next_query_classic", {})
        playbook_query_params = demisto.getLastRun().get("next_query_playbook", {})
        return self._request_raw(
            method="POST",
            url_suffix="/v3/alert/fetch",
            json_data={
                "integration_config": demisto.params(),
                "classic_query_params": classic_query_params,
                "playbook_query_params": playbook_query_params,
            },
            timeout=120,
        )


# === === === === === === === === === === === === === === ===
# === === === === === === ACTIONS === === === === === === ===
# === === === === === === === === === === === === === === ===


class Actions:
    def __init__(self, rf_client: Client):
        self.client = rf_client

    def test_module(self) -> None:
        # This is the call made when pressing the integration Test button.
        # Returning 'ok' indicates that the integration works like it suppose to and
        # connection to the service is successful.
        # Returning 'ok' will make the test result be green.
        # Any other response will make the test result be red.

        demisto_params = demisto.params()

        # Validate first_fetch
        first_fetch_str = str(demisto_params.get("first_fetch", ""))

        if first_fetch_str.isnumeric():
            first_fetch = int(first_fetch_str)
        else:
            raise ValueError("'first_fetch' parameter must be a number")
        ninety_days_in_minutes = 90 * 24 * 60
        if first_fetch > ninety_days_in_minutes:
            raise ValueError("'first_fetch' parameter cannot be bigger than 90 days")

        # Validate max_fetch
        max_fetch_str = str(demisto_params.get("max_fetch", ""))
        if max_fetch_str.isnumeric():
            max_fetch = int(max_fetch_str)
        else:
            raise ValueError("'max_fetch' parameter must be a number")
        if max_fetch > 50:
            raise ValueError("'max_fetch' parameter cannot be bigger than 50")

        try:
            self.client.whoami()
            return_results("ok")
        except Exception as err:
            message = str(err)
            try:
                error = json.loads(str(err).split("\n")[1])
                if "fail" in error.get("result", {}).get("status", ""):
                    message = error.get("result", {})["message"]
            except Exception:
                message = f"Unknown error. Please verify that the API URL and Token are correctly configured. RAW Error: {err}"
            raise DemistoException(f"Failed due to - {message}")

    def fetch_incidents(self) -> None:
        try:
            response = self.client.fetch_incidents()
        except DemistoException as err:
            if "404" in str(err):
                return_error("404 in fetch incidents")
                return
            raise

        alerts = response.get("alerts", [])
        next_query_classic = response.get("next_query_classic", {})
        next_query_playbook = response.get("next_query_playbook", {})
        next_query = {
            "next_query_classic": next_query_classic,
            "next_query_playbook": next_query_playbook,
        }

        incidents = [
            {
                "name": alert.get("title"),
                "occurred": alert.get("created"),
                "dbotMirrorId": alert.get("id"),
                "rawJSON": json.dumps(alert),
            }
            for alert in alerts
        ]

        demisto.incidents(incidents)
        demisto.setLastRun(next_query)

    def alert_search_command(self) -> list[CommandResults]:
        return self.client.alert_search()

    def alert_rule_search_command(
        self,
    ) -> list[CommandResults]:
        return self.client.alert_rule_search()

    def alert_update_command(self) -> list[CommandResults]:
        return self.client.alert_update()

    def alert_lookup_command(self) -> list[CommandResults]:
        alert_id = demisto.args().get("alert_id", "")
        return self.client.alert_lookup(alert_id)

    @staticmethod
    def _get_file_name_from_image_id(image_id: str) -> str:
        return f"{image_id.replace('img:', '')}.png"

    def _get_image_and_create_attachment(
        self,
        alert_type: str,
        alert_id: str,
        image_id: str,
        alert_subtype: str | None,
    ) -> dict | None:
        try:
            return_results(f"Trying to fetch {image_id=} ({alert_type=} {alert_subtype=} {alert_id=})")
            image_content = self.client.get_alert_image(
                alert_type=alert_type,
                alert_id=alert_id,
                image_id=image_id,
                alert_subtype=alert_subtype,
            )
            return_results(
                f"Fetched {image_id=} ({alert_type=} {alert_subtype=} {alert_id=}): {str(image_content[:50])} " f"(truncated)"
            )
            file_name = self._get_file_name_from_image_id(image_id)
            file_result_obj = fileResult(file_name, image_content)
            return_results(file_result_obj)  # Important
            attachment = {
                "description": "Alert image",
                "name": file_result_obj.get("File"),
                "path": file_result_obj.get("FileID"),
                "showMediaFile": True,
            }
            return attachment
        except Exception as e:
            demisto.error(f"Failed to fetch image {image_id}: {str(e)}")
            return None

    def get_alert_images_command(self) -> list[CommandResults]:
        incident = demisto.incident()
        if not isinstance(incident, dict) or incident.get("isPlayground") is True:
            return_error("This command can only run from an incident War Room context.")
            return []  # return_error will exit(0), but to make linter happy.

        custom_fields = incident.get("CustomFields")
        if not isinstance(custom_fields, dict):
            custom_fields = {}

        alert_id = custom_fields.get("alertid")
        if not alert_id:
            return_error("Failed to get alert id from the incident.")
            return []  # return_error will exit(0), but to make linter happy.

        lookup_result = self.client.alert_lookup(alert_id)

        if isinstance(lookup_result, list) and lookup_result and isinstance(lookup_result[0], CommandResults):
            lookup_data: dict = lookup_result[0].outputs  # type: ignore
        else:
            return_error("Failed to lookup alert.")
            return []  # return_error will exit(0), but to make linter happy.

        alert_type: str = lookup_data.get("type") or ""
        alert_subtype: str = lookup_data.get("subtype") or ""

        image_ids = lookup_data.get("images", []) or []

        if not image_ids:
            return [CommandResults(readable_output="No screenshots found in alert details.")]

        context = demisto.context() or {}

        files = demisto.get(context, "File")
        if not files:
            files = []
        if not isinstance(files, list):
            files = [files]

        existing_file_names = {f.get("Name") for f in files if isinstance(f, dict)}

        # Determine missing image IDs.
        missing_image_ids: set = set()
        for img_id in image_ids:
            # Limit to only 25 images.
            if len(missing_image_ids) >= MAX_IMAGES_TO_FETCH:
                break

            file_name = self._get_file_name_from_image_id(img_id)
            if file_name not in existing_file_names:
                missing_image_ids.add(img_id)

        if not missing_image_ids:
            return [CommandResults(readable_output="No new images to fetch.")]

        # Fetch missing images concurrently using thread pool.
        new_attachments = []
        with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
            futures = {}

            for img_id in missing_image_ids:
                future = executor.submit(
                    self._get_image_and_create_attachment,
                    alert_type=alert_type,
                    alert_id=alert_id,
                    image_id=img_id,
                    alert_subtype=alert_subtype,
                )
                futures[future] = img_id

            for future in concurrent.futures.as_completed(futures):
                attachment = future.result()
                if attachment:
                    new_attachments.append(attachment)

        if not new_attachments:
            return [
                CommandResults(
                    readable_output="No new images were fetched.",
                )
            ]

        message = f"Fetched {len(new_attachments)} new image(s)."
        return [
            CommandResults(
                readable_output=message,
            )
        ]


def get_client():
    demisto_params = demisto.params()

    base_url = demisto_params.get("url", "").rstrip("/")
    verify_ssl = not demisto_params.get("insecure", False)
    proxy = demisto_params.get("proxy", False)

    api_token = demisto_params.get("credentials", {}).get("password")
    if not api_token:
        return_error("Please provide a valid API token")

    headers = {
        "X-RFToken": api_token,
        "X-RF-User-Agent": (
            f"RecordedFuture.py/{__version__} ({platform.platform()}) "
            f"XSOAR/{__version__} "
            f"RFClient/{__version__} (Cortex_XSOAR_{demisto.demistoVersion()['version']})"
        ),
    }
    return Client(base_url=base_url, verify=verify_ssl, headers=headers, proxy=proxy)


def main():
    try:
        client = get_client()

        command = demisto.command()
        actions = Actions(client)

        if command == "test-module":
            actions.test_module()
        elif command == "fetch-incidents":
            actions.fetch_incidents()
        elif command == "rf-alert-rules":
            return_results(actions.alert_rule_search_command())
        elif command == "rf-alerts":
            return_results(actions.alert_search_command())
        elif command == "rf-alert-update":
            return_results(actions.alert_update_command())
        elif command == "rf-alert-lookup":
            return_results(actions.alert_lookup_command())
        elif command == "rf-alert-images":
            return_results(actions.get_alert_images_command())

    except Exception as e:
        return_error(
            message=f"Failed to execute {demisto.command()} command: {str(e)}",
            error=e,
        )


if __name__ in ("__main__", "__builtin__", "builtins"):  # pragma: no cover
    main()