ZeroFoxKeyIncidents

Cloud-based SaaS to detect risks found on social media and digital channels.

Data Enrichment & Threat Intelligence · ZeroFox

Details

IDZeroFoxKeyIncidents
ProviderHaveli Investments
CategoryData Enrichment & Threat Intelligence
From Version6.1.0
Docker Imagedemisto/python3:3.12.13.10116658
Supported ModulesAgentix XSIAM

README

Cloud-based SaaS to detect risks found on social media and digital channels.
This integration was integrated and tested with version 1.4.0 of ZeroFoxKey.

Configure ZeroFox Key Incidents in Cortex

Parameter Required
URL (e.g., https://api.zerofox.com/) True
Fetch incidents False
Username True
Password True
First fetch timestamp (<number> <time unit>, e.g., 12 hours, 7 days) False
Incident type 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.

zerofox-get-key-incident-attachment


Fetches a Key Incident Attachment by ID and uploads it to the current investigation War Room.

Base Command

zerofox-get-key-incident-attachment

Input

Argument Name Description Required
attachment_id The ID of the Key Incident Attachment. Required

Context Output

Path Type Description
File.Size Number The size of the file.
File.SHA1 String The SHA1 hash of the file.
File.SHA256 String The SHA256 hash of the file.
File.SHA512 String The SHA512 hash of the file.
File.Name String The name of the file.
File.SSDeep String The SSDeep hash of the file.
File.EntryID String The entry ID of the file.
File.Info String File information.
File.Type String The file type.
File.MD5 String The MD5 hash of the file.
File.Extension String The file extension.

Incident Mirroring

You can enable incident mirroring between Cortex XSOAR incidents and ZeroFox Key Incidents corresponding events (available from Cortex XSOAR version 6.0.0).
To set up the mirroring:

  1. Enable Fetching incidents in your instance configuration.

Newly fetched incidents will be mirrored in the chosen direction. However, this selection does not affect existing incidents.
Important Note: To ensure the mirroring works as expected, mappers are required, both for incoming and outgoing, to map the expected fields in Cortex XSOAR and ZeroFox Key Incidents.

Configuration parameters

  • url — URL (e.g., https://api.zerofox.com/) (required)
  • isFetch — Fetch incidents
  • credentials — Username (required)
  • first_fetch — First fetch timestamp (<number> <time unit>, e.g., 12 hours, 7 days)
  • incidentType — Incident type
  • incidentFetchInterval — Incidents Fetch Interval
  • max_fetch — Maximum number of incidents per fetch
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings

Commands (1)

  • zerofox-get-key-incident-attachment

    Fetches a Key Incident Attachment by ID and uploads it to the current investigation War Room.

import json
from dataclasses import asdict, dataclass
from typing import Any
from collections.abc import Callable
from urllib.parse import parse_qs, urlparse

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

""" GLOBALS / PARAMS  """
FETCH_TIME_DEFAULT = "7 days"

# Disable insecure warnings
urllib3.disable_warnings()


@dataclass
class KeyIncidentAttachment:
    content: str
    mime_type: str
    name: str
    created_at: str

    def to_dict(self):
        return asdict(self)


@dataclass
class KeyIncident:
    analysis: str
    created_at: datetime
    updated_at: datetime
    headline: str
    incident_id: str
    risk_level: str
    solution: str
    tags: list[str]
    threat_types: list[str]
    title: str
    url: str
    attachments: list[str]

    @classmethod
    def from_dict(cls, data: dict[str, Any]) -> "KeyIncident":
        return KeyIncident(
            analysis=data.get("analysis", ""),
            created_at=datetime.fromisoformat(data.get("created_at", "").replace("Z", "+00:00")),
            updated_at=datetime.fromisoformat(data.get("updated_at", "").replace("Z", "+00:00")),
            headline=data.get("headline", ""),
            incident_id=data.get("incident_id", ""),
            risk_level=data.get("risk_level", ""),
            solution=data.get("solution", ""),
            tags=data.get("tags", []),
            threat_types=data.get("threat_types", []),
            title=data.get("title", ""),
            url=data.get("url", ""),
            attachments=[_extract_ki_attachment_id(attch.get("url")) for attch in data.get("attachments", [])],
        )

    def to_dict(self):
        new_dict = asdict(self)
        new_dict["created_at"] = self.created_at.isoformat()
        new_dict["updated_at"] = self.updated_at.isoformat()
        return new_dict


@dataclass
class XSOARIncident:
    name: str
    occurred: str
    rawJSON: str
    dbotMirrorId: str

    def to_dict(self):
        return asdict(self)


def map_key_incident_to_xsoar(ki: KeyIncident) -> XSOARIncident:
    ki_as_dict = ki.to_dict()
    return XSOARIncident(
        name=f"{ki.incident_id} {ki.headline}",
        occurred=ki_as_dict.get("created_at", ""),
        rawJSON=json.dumps(ki_as_dict),
        dbotMirrorId=ki.incident_id,
    )


def get_last_incident_time(incidents: list[KeyIncident]):
    return max(x.updated_at.isoformat() for x in incidents)


class ZeroFox(BaseClient):
    def __init__(self, username, token, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.credentials = {"username": username, "password": token}
        self.access_token = None

    def _make_rest_call(
        self,
        method: str,
        url_suffix: str = "/",
        cti: bool = True,
        full_url: str | None = None,
        params: dict[str, str] | None = None,
        data: dict[str, Any] | None = None,
        ok_codes: tuple[int, ...] = None,
        empty_response: bool = False,
        error_handler: Callable[[Response], None] | None = None,
        **kwargs,
    ) -> dict[str, Any]:
        """
        :param method: HTTP request type
        :param url_suffix: The suffix of the URL
        :param cti: If the request is to cti endpoint
        :param params: The request's query parameters
        :param data: The request's body parameters
        :param empty_response: Indicates if the response data is empty or not
        :param error_handler: Function that receives the response and manage
        the error
        :return: Returns the content of the response received from the API.
        """
        headers = {}
        if cti:
            headers = self.get_cti_request_header()

        def err_handler(raw_response: Response):
            if error_handler is not None:
                error_handler(raw_response)
            self.handle_zerofox_error(raw_response)

        return self._http_request(
            method=method,
            url_suffix=url_suffix,
            full_url=full_url,
            headers=headers,
            params=params,
            json_data=data,
            ok_codes=ok_codes,
            return_empty_response=empty_response,
            error_handler=err_handler,
            **kwargs,
        )

    def handle_zerofox_error(self, raw_response: Response):
        status_code = raw_response.status_code
        if status_code >= 500:
            raise ZeroFoxInternalException(
                status_code=status_code,
                cause=raw_response.text,
            )
        cause = self._build_exception_cause(raw_response)
        response = raw_response.json()
        if status_code in [401, 403]:
            raise ZeroFoxAuthException(cause=cause)
        raise ZeroFoxInternalException(
            status_code=status_code,
            cause=str(response),
        )

    def _build_exception_cause(self, raw_response: Response) -> str:
        try:
            response = raw_response.json()
            if non_field_errors := response.get("non_field_errors", []):
                return non_field_errors[0]
            return str(response)
        except json.JSONDecodeError:
            return raw_response.text

    # CTI
    def get_cti_request_header(self) -> dict[str, str]:
        token: str = self.get_cti_authorization_token()
        return {
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json",
            "Accept": "application/json",
            "zf-source": "XSOAR",
        }

    def get_cti_authorization_token(self) -> str:
        """
        :return: returns the authorization token for the CTI feed
        """
        if self.access_token:
            return self.access_token
        token = self._get_new_access_token()
        self.access_token = token
        return token

    def _get_new_access_token(self) -> str:
        url_suffix: str = "/auth/token/"
        response_content = self._make_rest_call(
            "POST",
            url_suffix,
            data=self.credentials,
            cti=False,
        )
        return response_content.get("access", "")

    def _parse_cursor(self, url) -> str:
        query = parse_qs(urlparse(url).query)
        return query.get("cursor", [""])[0]

    def get_key_incidents(self, start_time, end_time) -> list[KeyIncident]:
        """
        :param start_time: The earliest point in time for which data should be fetched
        :param end_time: The latest point in time for which data should be fetched
        :return: HTTP request content.
        """
        url_suffix = "/cti/key-incidents/"
        params = remove_none_dict(
            {"updated_after": start_time, "updated_before": end_time, "ordering": "updated", "tags": "Key Incident"}
        )
        key_incidents = []
        response = self._make_rest_call(
            "GET",
            url_suffix,
            params=params,
        )
        key_incidents += [KeyIncident.from_dict(ki) for ki in response.get("results", [])]

        if next_page := response.get("next"):
            cursor = self._parse_cursor(next_page)
            params.update(cursor=cursor)
            response = self._make_rest_call(
                "GET",
                url_suffix,
                params=params,
            )
            key_incidents += [KeyIncident.from_dict(ki) for ki in response.get("results", [])]
        return key_incidents

    def get_key_incident_attachment(self, attachment_id: str) -> KeyIncidentAttachment:
        """
        :param attachment_id: The ID of the attachment to fetch
        :return: The attachment data
        """
        url_suffix = f"/cti/key-incident-attachment/{attachment_id}/"

        def error_handler(raw_response: Response):
            if raw_response.status_code == 404:
                raise ZeroFoxKIAttachmentNotFoundException

        response = self._make_rest_call(
            "GET",
            url_suffix,
            error_handler=error_handler,
        )
        mime_type, content = _parse_file_content(response.get("content"))

        return KeyIncidentAttachment(
            content=content, mime_type=mime_type, name=response["name"], created_at=response["created_at"]
        )


""" HELPERS """


def _extract_ki_attachment_id(url) -> str:
    return url.split("/")[-1]


def _parse_file_content(data_uri):
    header_data_match = re.match(r"data:(.*?);base64,(.+)", data_uri)
    if not header_data_match:
        raise ValueError("Invalid data URL format")
    mime_type, data = header_data_match.groups()

    return mime_type, data


class ZeroFoxKIAttachmentNotFoundException(Exception):
    def __init__(self):
        super().__init__("The requested Key Incident attachment was not found")


class ZeroFoxInternalException(Exception):
    def __init__(self, status_code: int, cause: str):
        self.status_code = status_code
        self.cause = cause
        super().__init__(self._generate_msg())

    def _generate_msg(self) -> str:
        return f"An error occurred within ZeroFox, please try again later.\
              If the issue persists, contact support.\
              Status Code: {self.status_code}, Response: {self.cause}"


class ZeroFoxAuthException(Exception):
    def __init__(self, cause: str):
        self.cause = cause
        super().__init__(self._generate_msg())

    def _generate_msg(self) -> str:
        return f"An error occurred while trying to authenticate with ZeroFox:\
            \n {self.cause}"


def remove_none_dict(input_dict: dict[Any, Any]) -> dict[Any, Any]:
    """
    removes all none values from a dict
    :param input_dict: any dictionary in the world is OK
    :return: same dictionary but without None values
    """
    return {key: value for key, value in input_dict.items() if value is not None}


""" COMMAND FUNCTIONS """


def conectivity_test(client: ZeroFox) -> str:
    """Tests API connectivity and authentication'

    Returning 'ok' indicates that the integration works like it is supposed to.
    Connection to the service is successful.
    Raises:
     exceptions if something goes wrong.

    Args:
        ZFClient: client to use

    Returns:
        'ok' if test passed, anything else will fail the test.
    """

    client.get_cti_authorization_token()
    return "ok"


def get_key_incident_attachment_command(client: ZeroFox, args: dict[str, Any]) -> CommandResults:
    attachment_id: str = args.get("attachment_id", "")
    try:
        attachment = client.get_key_incident_attachment(attachment_id)
        return fileResult(attachment.name, base64.b64decode(attachment.content))
    except ZeroFoxKIAttachmentNotFoundException:
        return CommandResults(
            readable_output=f"Key Incident attachment {attachment_id} was not found",
        )


def main():
    params = demisto.params()
    USERNAME: str = params.get("credentials", {}).get("identifier")
    API_KEY: str = params.get("credentials", {}).get("password")
    BASE_URL: str = params["url"][:-1] if params["url"].endswith("/") else params["url"]
    USE_SSL: bool = not params.get("insecure", False)
    PROXY: bool = params.get("proxy", False)
    FETCH_TIME: str = params.get(
        "first_fetch",
        FETCH_TIME_DEFAULT,
    ).strip()

    commands: dict[str, Callable[[ZeroFox, dict[str, Any]], Any]] = {
        "zerofox-get-key-incident-attachment": get_key_incident_attachment_command,
    }
    try:
        handle_proxy()
        command = demisto.command()

        client = ZeroFox(
            username=USERNAME,
            token=API_KEY,
            base_url=BASE_URL,
            ok_codes={200, 201},
            verify=USE_SSL,
            proxy=PROXY,
        )
        if command == "test-module":
            results = conectivity_test(client)
            return_results(results)
        elif command == "fetch-incidents":
            last_run, incidents = fetch_incidents(
                client,
                last_run=demisto.getLastRun(),
                first_fetch_time=FETCH_TIME,
            )
            demisto.setLastRun(last_run)
            demisto.incidents(incidents)
        elif command in commands:
            command_handler = commands[command]
            results = command_handler(client, demisto.args())
            return_results(results)
        else:
            raise NotImplementedError(f"Command {command} is not implemented")

    # Log exceptions and return errors
    except Exception as e:
        return_error(f"Failed to execute {command} command.\nError:\n{str(e)}")


def fetch_incidents(
    client: ZeroFox, last_run: dict[str, Any], first_fetch_time: str
) -> tuple[dict[str, Any], list[dict[str, Any]]]:
    start_time, end_time = get_fetch_run_time_range(last_run, first_fetch_time)
    incidents = client.get_key_incidents(start_time, end_time)

    if not incidents:
        last_run = {"time": end_time}
        return last_run, []

    xsoar_incidents = [map_key_incident_to_xsoar(item).to_dict() for item in incidents]

    last_run = {"time": get_last_incident_time(incidents)}

    return last_run, xsoar_incidents


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