Group-IB Digital Risk Protection

Pack helps to integrate Group-IB Digital Risk Protection and get violations incidents directly into Cortex XSOAR.

Data Enrichment & Threat Intelligence · Group-IB Digital Risk Protection

Details

IDGroup-IB Digital Risk Protection
CategoryData Enrichment & Threat Intelligence
From Version6.10.0
Docker Imagedemisto/vendors-sdk:1.0.0.10120494

README

Pack helps to integrate Group-IB Digital Risk Protection and get violations incidents directly into Cortex XSOAR.
This integration was integrated and tested with version 1.0 of Group-IB Digital Risk Protection.

Configure Group-IB Digital Risk Protection in Cortex

Parameter Description Required
GIB DRP URL   True
Fetch incidents   False
Incident type   False
Trust any certificate (not secure)   False
Use system proxy settings   False
Incidents Fetch Interval   False
Username   True
Password   True
Violation Section to filter the received Violation   False
Brands to filter the received Violation Brands for filtering received violations. The list of available brands can be obtained with the command !gibdrp-get-brands in the menu WarRoom -> Playground. After getting the brands you must specify the brand ID for which you want to receive violations. Attention! Currently filtering is available only by one brand in one Instance False
Incidents first fetch Date to start fetching incidents from. False
Download images Enables or disables loading of each image in each violation. Can significantly affect the speed of data collection if the parameter is enabled, i.e. set to True False
Getting Typosquatting only Allows for the collection of offenses suitable only for Typo Squatting False
Number of requests per collection A number of requests per collection that integration sends in one fetch iteration (each request picks up to 30 incidents). If you face some runtime errors, lower the value. True

Commands

You can execute these commands from the CLI, as part of an automation, or in a playbook.
After you successfully execute a command, a DBot message appears in the War Room with the command details.

gibdrp-get-brands


Receive all configured brands.

Base Command

gibdrp-get-brands

Input

| Argument Name | Description | Required |
| — | — | — |

Context Output

Path Type Description
GIBDRP.OtherInfo string List of configured brands.
GIBDRP.OtherInfo.brands.name string Brand name.
GIBDRP.OtherInfo.brands.id string Brand ID.

Command example


#### Context Example

```json
{
    "GIBDRP": {
        "OtherInfo": {
            "brands": [
                {
                    "id": "PvY1BZUBSFbLZGo2x8TA",
                    "name": "Example Brand"
                }
            ]
        }
    }
}

Human Readable Output

Installed Brands

Name Id
Example Brand PvY1BZUBSFbLZGo2x8TA

gibdrp-get-subscriptions


Receive all configured subscriptions.

Base Command

gibdrp-get-subscriptions

Input

| Argument Name | Description | Required |
| — | — | — |

Context Output

Path Type Description
GIBDRP.OtherInfo string List of configured subscriptions.
GIBDRP.OtherInfo.subscriptions string Subscriptions list.

Command example


#### Context Example

```json
{
    "GIBDRP": {
        "OtherInfo": {
            "subscriptions": [
                "scam"
            ]
        }
    }
}

Human Readable Output

Purchased subscriptions

Subscriptions
scam

gibdrp-get-violation-by-id


Getting a single violation by its ID.

Base Command

gibdrp-get-violation-by-id

Input

Argument Name Description Required
id ID violation. Required

Context Output

Path Type Description
GIBDRP.Violation.id string Violation ID.
GIBDRP.Violation.title string Violation title.
GIBDRP.Violation.description string Violation description.
GIBDRP.Violation.status string Violation status.
GIBDRP.Violation.violation_uri string Violation URI.
GIBDRP.Violation.source string Violation source section.
GIBDRP.Violation.detected date Detected timestamp.

gibdrp-change-violation-status


Changing the status of a single violation.

Base Command

gibdrp-change-violation-status

Input

Argument Name Description Required
id ID violation. Required
status What status to change to. Possible values are: approve, reject. Required

Context Output

There is no context output for this command.

Configuration parameters

  • url — GIB DRP URL (required)
  • isFetch — Fetch incidents
  • incidentType — Incident type
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings
  • incidentFetchInterval — Incidents Fetch Interval
  • credentials — Username (required)
  • violationSection — Filter by Violation Type
  • brands — Filter by Brand
  • first_fetch — Incidents first fetch
  • download_images — Download images
  • only_typosquatting — Get Typosquatting Only
  • max_fetch — Number of requests per collection (required)

Commands (4)

  • gibdrp-change-violation-status

    Changing the status of a single violation.

  • gibdrp-get-brands

    Receive all configured brands.

  • gibdrp-get-subscriptions

    Receive all configured subscriptions.

  • gibdrp-get-violation-by-id

    Getting a single violation by its ID.

import demistomock as demisto
from CommonServerPython import *
from CommonServerUserPython import *


""" IMPORTS """

from urllib3.exceptions import InsecureRequestWarning
from urllib3 import disable_warnings as urllib3_disable_warnings
from cyberintegrations import DRPPoller
from traceback import format_exc
from enum import Enum
from dateparser import parse as dateparser_parse  # type: ignore
from json import dumps as json_dumps
import base64
from cyberintegrations.exception import ConnectionException
from cyberintegrations.cyberintegrations import Parser
from cyberintegrations.const import TechnicalConsts
import re

# Disable insecure warnings
urllib3_disable_warnings(InsecureRequestWarning)

""" CONSTANTS """

DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"


class ViolationTypeMapping(Enum):
    WEB = 1
    MARKETPLACE = 3
    ADVERTISING = 5
    MOBILE_APPS = 2
    SOCIAL_NETWORKS = 4
    INSTANT_MESSENGERS = 6


class ViolationSubType(Enum):
    Counterfeit = 1
    Piracy = 2
    Partner_policy_compliance = 3
    Trademark = 4
    Malware = 5
    Phishing = 6
    Fraud = 7
    NoViolation = 8


COMMON_VIOLATION_MAPPING = {
    # Start Information From Group-IB DRP
    "id": "id",  # GIB DRP ID
    "title": "violation.title",  # GIB DRP Title
    "description": "violation.description",  # GIB DRP Description
    "brand": "brand",  # GIB DRP Brand
    "company": "company",  # GIB DRP Company
    "violation_uri": "violation.uri",  # GIB DRP VIOLATION URI
    "approve_state": "violation.approveState",  # GIB DRP Approve State
    "violation_status": "violation.status",  # GIB DRP Status
    "source": "violation.source",  # GIB DRP Source
    "violation_type": "violation.violationSubtype",  # GIB DRP Type
    "tags": "violation.tags.name",  # GIB DRP Tags
    "link": "link",  # GIB DRP Link
    "typosquatting_status": "*typosquatting_status",  # GIB DRP Typosquatting Status
    # End Information From Group-IB DRP
    # Start Group-IB Dates
    "detected": "violation.detected",  # GIB DRP Detected
    "first_detected": "violation.firstDetected",  # GIB DRP First Detected
    "first_active": "violation.firstActive",  # GIB DRP First Active
    "first_solved": "violation.firstSolved",  # GIB DRP First Solved
    "dates_found_date": "violation.dates.foundDate",  # GIB DRP Found
    "dates_created_date": "violation.dates.createdDate",  # GIB DRP Created
    "dates_current_status_date": "violation.dates.currentStatusDate",  # GIB DRP Current Status Date
    "dates_approved_date": "violation.dates.approvedDate",  # GIB DRP Approved
    # End Group-IB Dates
    # Start Group-IB Images
    "images": "images",  # GIB DRP HTML Images
    # End Group-IB Images
    # Start Group-IB Tables
    "scores": {  # GIB DRP Scores Table
        "score": "violation.scores.score",
        "type": "violation.scores.type",
        "version": "violation.scores.version",
    },
    # End Group-IB Tables
}


TABLES_MAPPING = ["scores", "stages"]

STATUS_CODE_MSGS = {
    401: "Bad Credentials",
    403: "Something is wrong with your account, please, contact GIB.",
    404: "Not found. There is no such data on server.",
    500: "There are some troubles on server with your request.",
    301: "Verify that your public IP is whitelisted by Group IB.",
    302: "Verify that your public IP is whitelisted by Group IB.",
}
TIMEOUT = 360
RETRIES = 4
STATUS_LIST_TO_RETRY = [429, 500]


class Endpoints(Enum):
    VIOLATIONS = "violation/list"
    VIOLATION = "violation"
    BRANDS = "/settings/brands"
    SUBSCRIPTIONS = "/settings/subscriptions"
    RECEIVING_FILE = "/file/"
    CHANGE_APPROVE = "/violation/change-approve"


class Client(BaseClient):
    """
    Client will implement the service API, and should not contain any Demisto logic.
    Should only do requests and return data.
    """

    def __init__(self, base_url, auth: tuple[str, str], verify=True, proxy=False):
        super().__init__(base_url=base_url, verify=verify, proxy=proxy, auth=auth)

        self.poller = DRPPoller(
            username=auth[0],
            api_key=auth[1],
            api_url=base_url,
        )
        self.poller.set_product(
            product_type="SOAR",
            product_name="CortexSOAR",
            product_version="unknown",
            integration_name="Group-IB Digital Risk Protection",
            integration_version="1.1.0",
        )
        self.additional_headers = {
            "Accept": "*/*",
            "User-Agent": f"SOAR/CortexSOAR_unknown/Group-IB Digital Risk Protection/{auth[0]}",
        }

    def generate_seq_update(self, first_fetch_time: str) -> int:
        demisto.debug(f"Client.generate_seq_update: first_fetch_time='{first_fetch_time}'")
        date_from = dateparser_parse(date_string=first_fetch_time)
        if date_from is None:
            raise DemistoException(
                "Inappropriate first_fetch format, "
                f"please use a format such as: 2020-01-01 or January 1 2020 or 3 days. The format given is: {date_from}"
            )
        date_from = date_from.strftime("%Y-%m-%d")
        demisto.debug(f"Client.generate_seq_update: date_from='{date_from}'")
        raw_seq = self.poller.get_seq_update_dict(date=date_from, collection=Endpoints.VIOLATIONS.value)
        demisto.debug(f"Client.generate_seq_update: raw_seq(type={type(raw_seq).__name__})={raw_seq!r}")

        # Verify the return type of get_seq_update_dict:
        # With `collection` provided, the get_seq_update_dict is expected to return an integer seqUpdate.
        if isinstance(raw_seq, int) and not isinstance(raw_seq, bool):
            return raw_seq

        raise DemistoException(
            "DRPPoller.get_seq_update_dict returned unexpected type. "
            "Expected int seqUpdate when 'collection' is provided. "
            f"Got type={type(raw_seq).__name__}, value={raw_seq!r}"
        )

    def _get_violation_section_number(self, name: str) -> int:
        normalized_name = name.upper()
        normalized_name = normalized_name.replace(" ", "_")

        try:
            return ViolationTypeMapping[normalized_name].value
        except KeyError:
            raise ValueError(f"Unknown violation type: {name}")

    def create_generator(
        self,
        first_fetch_time: str,
        last_run: dict,
        only_typosquatting: bool,
        violation_subtypes: list[str] | None = None,
        brands: str | None = None,
        section: str | None = None,
    ):
        last_fetch = last_run.get("last_fetch", None)
        demisto.debug(f"Client.create_generator: last_fetch={last_fetch!r}")
        use_last_fetch = isinstance(last_fetch, int) and last_fetch > 0
        sequpdate: int = last_fetch if use_last_fetch else self.generate_seq_update(first_fetch_time)
        demisto.debug(
            "Client.create_generator: sequpdate selection - "
            f"selected={sequpdate} source={'last_run.last_fetch' if use_last_fetch else 'generate_seq_update(first_fetch_time)'} "
            f"first_fetch_time={first_fetch_time!r}"
        )

        if section:
            section: int = self._get_violation_section_number(section.strip())  # type: ignore[no-redef]

        if brands:
            brands = brands.strip(",")

        demisto.debug(
            "Client.create_generator: "
            f"collection={Endpoints.VIOLATIONS.value} subtypes={violation_subtypes} section={section} "
            f"sequpdate={sequpdate} brands={brands}"
        )
        try:
            return self.poller.create_update_generator(
                collection_name=Endpoints.VIOLATIONS.value,
                subtypes=violation_subtypes,
                section=section,
                brands=brands,
                sequpdate=sequpdate,
                use_typo_squatting=only_typosquatting,
            )
        except ConnectionException as e:
            raise ConnectionException(
                f"Additional information: collection_name: {Endpoints.VIOLATIONS.value} "
                f"subtypes: {violation_subtypes} section: {section} sequpdate: {sequpdate} {str(e)}"
            ) from e

    def change_violation_status(self, feed_id: str, status: str) -> int | str:
        """
        Status could be approve or reject
        """
        approve_statuses = {"approve": True, "reject": False}
        approve_status = approve_statuses.get(status)
        response = self.poller.search_feed_by_id(feed_id)
        demisto.debug(
            "Client.change_violation_status: "
            f"id={feed_id} approve={approve_status} "
            f"current_status={response.raw_dict.get('violation', {}).get('status')} "
            f"approve_state={response.raw_dict.get('violation', {}).get('approveState')}"
        )
        violation_status = response.raw_dict.get("violation", {}).get("status", None)
        violation_approve_state = response.raw_dict.get("violation", {}).get("approveState", None)
        if violation_status == "detected" and violation_approve_state == "under_review":
            response = self._http_request(
                method="POST",
                url_suffix=Endpoints.CHANGE_APPROVE.value,
                timeout=TIMEOUT,
                retries=RETRIES,
                status_list_to_retry=STATUS_LIST_TO_RETRY,
                headers=self.additional_headers,
                json_data={"violationId": feed_id, "approve": approve_status},
                resp_type="response",
            )
            return response.status_code
        else:
            demisto.debug(
                "Client.change_violation_status: cannot change violation status due to current state - "
                f"id={feed_id} requested={status!r} "
                f"current_status={violation_status!r} approve_state={violation_approve_state!r} "
                "expected: status='detected' and approveState='under_review'"
            )
            return "Can not change the status of the selected feed"

    def get_formatted_brands(self) -> list[dict[str, str]]:
        if hasattr(self.poller, "get_brands"):
            try:
                brands_data = self.poller.get_brands()  # type: ignore[attr-defined]
                return brands_data or []
            except Exception as e:
                demisto.debug(f"get_brands failed: {e}")
                return []
        demisto.debug(
            "DRPPoller.get_brands is not available; returning empty list. " f"Library version: {TechnicalConsts.library_version}"
        )
        return []

    def get_formatted_subscriptions(self) -> list[str]:
        if hasattr(self.poller, "get_subscriptions"):
            try:
                return self.poller.get_subscriptions() or []  # type: ignore[attr-defined]
            except Exception as e:
                demisto.debug(
                    f"DRPPoller.get_subscriptions exception ({type(e).__name__}): {e!s}. "
                    f"poller={type(self.poller).__name__}, library_version={TechnicalConsts.library_version}. "
                    "Returning empty list."
                )
                return []
        demisto.debug(
            "DRPPoller.get_subscriptions is not available; returning empty list. "
            f"Library version: {TechnicalConsts.library_version}"
        )
        return []

    def get_file(self, file_sha: str) -> tuple[bytes, str] | None:
        try:
            response = self._http_request(
                method="GET",
                url_suffix=Endpoints.RECEIVING_FILE.value + file_sha,
                timeout=TIMEOUT,
                retries=RETRIES,
                status_list_to_retry=STATUS_LIST_TO_RETRY,
                headers=self.additional_headers,
                resp_type="response",
            )
            mime_type = CommonHelpers.extract_mime_type(response.headers.get("content-type", ""))
            content_len = len(response.content) if hasattr(response, "content") and response.content is not None else 0
            status_code = getattr(response, "status_code", None)
            demisto.debug(
                "Client.get_file: downloaded file - "
                f"file_sha={file_sha} status_code={status_code} mime_type={mime_type} content_len={content_len}"
            )
            data = response.content, mime_type
        except Exception as e:
            data = None
            demisto.debug(
                "Client.get_file: Could not download or the following image is not available - "
                f"file_sha={file_sha} error_type={type(e).__name__} error={e!s}\n{format_exc()}"
            )
        return data

    def get_violation_by_id(self, violation_id: str) -> Parser:
        results = self.poller.search_feed_by_id(violation_id)
        return results

    def get_formatted_violation_by_id(
        self, violation_id: str, get_images: bool | None = True
    ) -> tuple[dict[Any, Any], list[dict[str, str | bytes]]]:
        results = self.get_violation_by_id(violation_id=violation_id)
        parse_result: dict[Any, Any] = results.parse_portion(keys=COMMON_VIOLATION_MAPPING, as_json=False)[0]
        updated_images = []
        if get_images:
            images = parse_result.get("images", [])
            if images and len(images) > 0:
                for image in images:
                    image_data_and_mime_type = self.get_file(file_sha=image)
                    if image_data_and_mime_type is not None:
                        image_data, mime_type = image_data_and_mime_type
                        demisto.debug(f"Client.get_formatted_violation_by_id: image mime_type={mime_type}")
                        updated_images.append(
                            {
                                "file_sha": image,
                                "image_data": image_data,
                                "mime_type": mime_type,
                            }
                        )

        return parse_result, updated_images


""" Support functions """


class CommonHelpers:
    scores_tables_name_by_types = {
        "risk": "General Score ",
        "domain": "Domain Score ",
        "image": "Image Score ",
        "parking": "Parking Score ",
        "text": "Text Score ",
    }

    @staticmethod
    def transform_dict(input_dict: dict[str, list[str | list[Any]] | str | None]) -> list[dict[str, Any]]:
        if not input_dict:
            return [{}]

        normalized_dict: dict[str, list[Any]] = {}
        for k, v in input_dict.items():
            if isinstance(v, list):
                normalized_dict[k] = v
            elif v is None:
                normalized_dict[k] = []
            else:
                normalized_dict[k] = [v]

        max_length = max((len(v) for v in normalized_dict.values() if isinstance(v, list)), default=1)

        result = []
        for i in range(max_length):
            result.append({k: (v[i] if i < len(v) else (v[0] if v else None)) for k, v in normalized_dict.items()})

        return result

    @staticmethod
    def transform_additional_fields_to_markdown_tables(feed: dict):
        additional_tables = []
        delete_keys = []
        for key, value in feed.items():
            if key == "scores" and isinstance(value, dict):
                additional_data = CommonHelpers.transform_dict(value)
                position_score_dict = [item for item in additional_data if item.get("type") == "position"][0]
                additional_data = [item for item in additional_data if item.get("type") != "position"]

                for item in additional_data:
                    value_type = item.get("type")
                    table_name = "Table"
                    if value_type == "risk":
                        item.update(
                            {
                                "position_score": position_score_dict.get("score"),
                                "position_version": position_score_dict.get("version"),
                            }
                        )
                    elif value_type is not None:
                        table_name = CommonHelpers.scores_tables_name_by_types.get(value_type, "Table")

                    table = CommonHelpers.get_human_readable_feed(
                        table=item,
                        name=table_name,
                    )
                    additional_tables.append(
                        CommandResults(
                            readable_output=table,
                            ignore_auto_extract=True,
                        )
                    )

                delete_keys.append(key)

            elif isinstance(value, dict):
                additional_data = CommonHelpers.transform_dict(value)
                for index, item in enumerate(additional_data):
                    table = CommonHelpers.get_human_readable_feed(table=item, name=f"{key} table {index}")
                    additional_tables.append(
                        CommandResults(
                            readable_output=table,
                            ignore_auto_extract=True,
                        )
                    )
                delete_keys.append(key)

        for key in delete_keys:
            feed.pop(key)

        return feed, additional_tables

    @staticmethod
    def get_human_readable_feed(table: dict[Any, Any], name: str):
        return tableToMarkdown(
            name=name,
            t=table,
            removeNull=True,
        )

    @staticmethod
    def get_table_data(
        feed: dict[Any, Any],
    ):
        updated_feed, additional_tables = CommonHelpers.transform_additional_fields_to_markdown_tables(feed)

        return updated_feed, additional_tables

    @staticmethod
    def violation_source_mapping(feed: dict) -> dict:
        source = feed.get("source")
        feed["source"] = ViolationTypeMapping(source).name
        return feed

    @staticmethod
    def convert_iso8601_with_timezone(date_str: str):
        if not re.match(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+\d{4}$", date_str):
            raise ValueError("Invalid date format. A string in the following format is expected 'YYYY-MM-DDTHH:MM:SS+0000'.")

        date_part = date_str[:19]  # '2024-10-30T15:12:34'
        timezone_part = date_str[19:]  # '+0000'

        formatted_timezone = f"{timezone_part[:3]}:{timezone_part[3:]}"  # '+00:00'

        formatted_date = f"{date_part}{formatted_timezone}"

        return formatted_date

    @staticmethod
    def format_dates_in_dict(data: dict):
        date_keys = [
            "dates_created_date",
            "dates_found_date",
            "dates_approved_date",
            "dates_current_status_date",
            "datetime",
            "first_detected",
            "first_active",
            "first_solved",
            "detected",
            "stages",
        ]
        for key, value in data.items():
            if key in date_keys and value is not None:
                if isinstance(value, str):
                    data[key] = CommonHelpers.convert_iso8601_with_timezone(value)
                elif isinstance(value, dict):
                    CommonHelpers.format_dates_in_dict(value)
                elif isinstance(value, list):
                    data[key] = list(map(CommonHelpers.convert_iso8601_with_timezone, value))
        return data

    @staticmethod
    def all_lists_empty(data: dict[str, Any] | list[Any]) -> bool:
        all_empty = True

        if isinstance(data, dict):
            for value in data.values():
                if isinstance(value, list):
                    if value:
                        all_empty = False
                elif isinstance(value, dict) and not CommonHelpers.all_lists_empty(value):
                    all_empty = False
        elif isinstance(data, list):
            for item in data:
                if isinstance(item, dict) and not CommonHelpers.all_lists_empty(item):
                    all_empty = False

        return all_empty

    @staticmethod
    def replace_empty_values(data: dict[str, Any] | list[dict[str, Any]]) -> dict[str, Any] | list[dict[str, Any]]:
        if isinstance(data, dict):
            return {key: CommonHelpers.replace_empty_values(value) for key, value in data.items()}

        elif isinstance(data, list):
            if not data:
                return None  # type: ignore

            if all(isinstance(item, list) and not item for item in data):
                return None  # type: ignore

            return [CommonHelpers.replace_empty_values(item) for item in data]  # type: ignore

        else:
            if data == "":
                return None
            return data

    @staticmethod
    def remove_underscore_and_lowercase_keys(dict_list: list[dict[str, Any]] | list[dict[str, Any]]) -> list[dict[str, Any]]:
        updated_dicts = []

        for d in dict_list:
            new_dict = {}
            for key, value in d.items():
                new_key = key.replace("_", "").lower()
                new_dict[new_key] = value

            updated_dicts.append(new_dict)

        return updated_dicts

    @staticmethod
    def data_pre_cleaning(violation: dict[str, Any]) -> dict[str, Any]:
        demisto.debug(
            f"CommonHelpers.data_pre_cleaning start: keys={list(violation.keys())}, "
            f"uri='{violation.get('violation_uri', '')}'"
        )
        violation_uri: str = violation.get("violation_uri", "")
        if violation_uri.startswith("//"):
            violation_uri = violation_uri[2:]

        violation["violation_uri"] = violation_uri

        tags = violation.get("tags")
        if tags:
            tags = [item for item in tags if item is not None]

        violation["tags"] = tags

        demisto.debug(
            f"CommonHelpers.data_pre_cleaning done: uri='{violation_uri}', "
            f"tags_count={len(tags) if isinstance(tags, list) else 0}, keys={list(violation.keys())}"
        )
        return violation

    @staticmethod
    def extract_mime_type(content_type: str) -> str:
        match = re.match(r"^\s*([^;]+)", content_type)
        return match.group(1).strip() if match else "image/jpeg"

    @staticmethod
    def set_tag_downloaded_by_typoSquatting(violation: dict[str, Any], only_typosquatting: bool) -> dict[str, Any]:
        if violation.get("typosquatting_status", None) and only_typosquatting:
            violation["typosquatting_status"] = True
        else:
            violation["typosquatting_status"] = False
        return violation


class IncidentBuilder:
    def __init__(
        self,
        client: Client,
        last_run: dict,
        first_fetch_time: str,
        max_requests: int,
        download_images: bool,
        only_typosquatting: bool,
        violation_subtypes: list[str] | None,
        violation_section: str | None,
        brands: str | None,
    ) -> None:
        self.client = client
        self.last_run = last_run
        self.first_fetch_time = first_fetch_time
        self.max_requests = max_requests
        self.violation_subtypes = violation_subtypes
        self.violation_section = violation_section
        self.brands = brands
        self.download_images = download_images
        self.only_typosquatting = only_typosquatting

    def transform_fields_to_grid_table(self, incident: dict):
        if TABLES_MAPPING:
            for field in TABLES_MAPPING:
                field_data = incident.get(field, {})
                if field_data and CommonHelpers.all_lists_empty(field_data) is False:
                    transformed_data = CommonHelpers.transform_dict(input_dict=field_data)

                    transformed_and_replaced_empty_values_data = CommonHelpers.replace_empty_values(transformed_data)
                    clean_data = CommonHelpers.remove_underscore_and_lowercase_keys(
                        transformed_and_replaced_empty_values_data  # type: ignore
                    )
                    if field == "scores":
                        clean_data = [item for item in clean_data if item["type"] != "position"]
                        for score in clean_data:
                            score_type = score.get("type")
                            if isinstance(score_type, str):
                                score["type"] = CommonHelpers.scores_tables_name_by_types.get(score_type, "Unknown")
                            else:
                                score["type"] = "Unknown"

                        demisto.debug(
                            "IncidentBuilder.transform_fields_to_grid_table: scores normalized "
                            f"(count={len(clean_data)}, types={[item.get('type') for item in clean_data]})"
                        )

                    incident[field] = clean_data
                else:
                    incident[field] = None

        return incident

    def build(self) -> tuple[dict[str, int | Any], list]:
        previous_last_fetch = self.last_run.get("last_fetch")
        next_run: dict[str, int | Any] = {"last_fetch": previous_last_fetch}
        violations: list[dict[str, Any]] = []
        requests_count = 0
        max_seq_update: int | None = None

        portions = self.client.create_generator(
            violation_subtypes=self.violation_subtypes,
            section=self.violation_section,
            brands=self.brands,
            first_fetch_time=self.first_fetch_time,
            last_run=self.last_run,
            only_typosquatting=self.only_typosquatting,
        )
        for portion in portions:
            portion_sequpdate = getattr(portion, "sequpdate", None)
            demisto.debug(
                "IncidentBuilder.build: processing portion - "
                f"requests_count={requests_count} max_requests={self.max_requests} "
                f"portion_sequpdate={portion_sequpdate!r}"
            )
            sequpdate = portion.sequpdate
            parse_result: list[dict[Any, Any]] = portion.parse_portion(keys=COMMON_VIOLATION_MAPPING, as_json=False)
            demisto.debug(
                "IncidentBuilder.build: portion parsed - " f"portion_sequpdate={sequpdate!r} parsed_items={len(parse_result)}"
            )

            created_before = len(violations)
            max_seq_before = max_seq_update

            for feed in parse_result:
                feed = CommonHelpers.data_pre_cleaning(violation=feed)
                feed = CommonHelpers.violation_source_mapping(feed=feed)
                feed = CommonHelpers.format_dates_in_dict(data=feed)
                feed = CommonHelpers.set_tag_downloaded_by_typoSquatting(
                    violation=feed, only_typosquatting=self.only_typosquatting
                )
                incident = self.transform_fields_to_grid_table(incident=feed)

                if self.download_images:
                    images = incident.get("images", [])
                    updated_images = []

                    if images and len(images) > 0:
                        for image in images:
                            image_data = self.client.get_file(file_sha=image)
                            if image_data:
                                image_bytes, mime_type = image_data
                                demisto.debug(f"IncidentBuilder.build: image mime_type={mime_type}")
                                image_base64_uri = f"data:{mime_type};base64,{base64.b64encode(image_bytes).decode('utf-8')}"
                                image_html = f'<img src="{image_base64_uri}" alt="Violation Incident Image" />'
                                updated_images.append(image_html)

                    if len(updated_images) > 0:
                        incident["images"] = "<br/>".join(updated_images)
                    else:
                        incident.pop("images")
                else:
                    incident.pop("images")

                # Prefer standard fields: name from title, occurred from created date (fallback to detected)
                preferred_name = incident.get("title") or f"Violation {incident.get('id')}"
                preferred_occurred = incident.get("dates_created_date") or incident.get("detected")
                incident.update(
                    {
                        "name": preferred_name,
                        "occurred": preferred_occurred,
                        "gibType": Endpoints.VIOLATIONS.value,
                    }
                )
                violations.append(
                    {
                        "name": incident.get("name"),
                        "occurred": incident.get("occurred"),
                        "rawJSON": json_dumps(incident),
                        "dbotMirrorId": incident.get("id"),
                    }
                )
            # Track the highest seqUpdate seen in this run
            try:
                if isinstance(sequpdate, int):
                    current_max = max_seq_update if isinstance(max_seq_update, int) else 0
                    max_seq_update = max(sequpdate, current_max)
            except Exception as e:
                demisto.debug(
                    "IncidentBuilder.build: failed to compare/track seqUpdate; skipping. "
                    f"sequpdate={sequpdate!r} max_seq_update={max_seq_update!r} "
                    f"error_type={type(e).__name__} error={e!s}\n{format_exc()}"
                )
            requests_count += 1
            created_after = len(violations)
            demisto.debug(
                "IncidentBuilder.build: portion done - "
                f"portion_sequpdate={sequpdate!r} created_in_portion={created_after - created_before} "
                f"max_seq_update_before={max_seq_before!r} max_seq_update_after={max_seq_update!r} "
                f"requests_count={requests_count} max_requests={self.max_requests}"
            )
            if requests_count > self.max_requests:
                demisto.debug(
                    "IncidentBuilder.build: stopping due to max_requests limit - "
                    f"requests_count={requests_count} max_requests={self.max_requests} "
                    f"last_portion_sequpdate={sequpdate!r} max_seq_update={max_seq_update!r}"
                )
                break
        # Decide effective next_run.last_fetch
        effective_last = previous_last_fetch
        if isinstance(max_seq_update, int) and max_seq_update > 0:
            if isinstance(previous_last_fetch, int) and previous_last_fetch > 0:
                effective_last = max(previous_last_fetch, max_seq_update)
            else:
                effective_last = max_seq_update
        else:
            demisto.debug(
                "IncidentBuilder.build: not updating last_fetch because no seqUpdate was observed - "
                f"previous_last_fetch={previous_last_fetch!r} max_seq_update={max_seq_update!r} "
                f"requests_count={requests_count} created_incidents={len(violations)}"
            )
        next_run["last_fetch"] = effective_last
        demisto.debug(
            "IncidentBuilder.build: "
            f"computed next_run.last_fetch={effective_last} "
            f"(prev={previous_last_fetch}, max_seq={max_seq_update})"
        )
        return next_run, violations


class BuilderCommandResponses:
    def __init__(
        self,
        requested_method: str,
        client: Client,
        args: dict,
        first_fetch: str,
        max_requests: int,
    ) -> None:
        self.requested_method = requested_method
        self.client = client
        self.args = args
        self.first_fetch = first_fetch
        self.max_requests = max_requests

    def get_brands(self) -> CommandResults:
        response_result = self.client.get_formatted_brands()
        readable_output = tableToMarkdown(
            name="Installed Brands",
            t=response_result,
            headers=["name", "id"],
            headerTransform=lambda x: x.capitalize(),
        )

        return CommandResults(
            outputs_prefix="GIBDRP.OtherInfo",
            outputs_key_field="id",
            outputs={"brands": response_result},
            readable_output=readable_output,
            ignore_auto_extract=True,
            raw_response=response_result,
        )

    def get_subscriptions(self) -> CommandResults:
        response_result = self.client.get_formatted_subscriptions()
        readable_output = tableToMarkdown(
            name="Purchased subscriptions",
            t=response_result,
            headers="Subscriptions",
        )
        return CommandResults(
            outputs_prefix="GIBDRP.OtherInfo",
            outputs_key_field="subscriptions",
            outputs={"subscriptions": response_result},
            readable_output=readable_output,
            ignore_auto_extract=True,
            raw_response=response_result,
        )

    def get_violation_by_id(self) -> list[CommandResults]:
        """
        the returned list has dict[str, Any], which is fileResult.
        And an important note, not necessarily picture files, i.e. fileResults definitely will be.
        """
        id_ = str(self.args.get("id"))
        parse_result, updated_images = self.client.get_formatted_violation_by_id(violation_id=id_)
        parse_result: dict = CommonHelpers.data_pre_cleaning(violation=parse_result)
        parse_result = CommonHelpers.violation_source_mapping(feed=parse_result)
        updated_feed, additional_tables = CommonHelpers.get_table_data(feed=parse_result)
        readable_output = CommonHelpers.get_human_readable_feed(table=updated_feed, name=f"Feed {id_}")
        results = []
        results.append(
            CommandResults(
                outputs_key_field="id",
                outputs=updated_feed,
                readable_output=readable_output,
                raw_response=updated_feed,
                ignore_auto_extract=True,
            )
        )
        results.extend(additional_tables)
        if updated_images:
            for updated_image in updated_images:
                results.append(
                    fileResult(
                        filename=f"Attached image {updated_image.get('file_sha', 'default')}",  # type: ignore
                        data=updated_image.get("image_data", ""),
                    )
                )
        return results

    def change_violation_status(self) -> str:
        id_ = str(self.args.get("id"))
        status = str(self.args.get("status"))
        result = self.client.change_violation_status(feed_id=id_, status=status)
        demisto.debug(f"change_violation_status {id_} {status} {result}")
        if result == 200:
            return "Request to change violation status sent"
        else:
            return result  # type: ignore[return-value]

    def build(self) -> str | tuple[CommandResults, dict[str, Any]] | CommandResults:
        # Check if the method exists in the class
        if hasattr(self, self.requested_method) and callable(getattr(self, self.requested_method)):
            # Call the method
            return getattr(self, self.requested_method)()
        else:
            raise AttributeError(f"Method {self.requested_method} is not implemented.")


""" Commands """


class Commands:
    methods_requiring_return_results = {
        "get_brands",
        "get_subscriptions",
        "get_violation_by_id",
        "change_violation_status",
        "test_module",
    }

    def __init__(
        self,
        client: Client,
        command: str,
        args: dict,
        first_fetch: str,
        max_requests: int,
        download_images: bool,
        only_typosquatting: bool,
        violation_subtypes: list[str] | list | None,
        violation_section: str | None = None,
        brands: str | None = None,
    ) -> None:
        self.client = client
        self.command = command
        self.args = args
        self.first_fetch = first_fetch
        self.max_requests = max_requests
        self.last_run = demisto.getLastRun()
        self.requested_method = self.command.replace("gibdrp-", "").replace("-", "_")
        self.violation_subtypes = violation_subtypes
        self.violation_section = violation_section
        self.brands = brands
        self.download_images = download_images
        self.only_typosquatting = only_typosquatting

    def get_brands(self) -> str | tuple[CommandResults, dict[str, Any]] | CommandResults:
        results = BuilderCommandResponses(
            self.requested_method,
            self.client,
            self.args,
            self.first_fetch,
            self.max_requests,
        ).build()
        return results

    def get_subscriptions(self) -> str | tuple[CommandResults, dict[str, Any]] | CommandResults:
        results = BuilderCommandResponses(
            self.requested_method,
            self.client,
            self.args,
            self.first_fetch,
            self.max_requests,
        ).build()
        return results

    def get_violation_by_id(self) -> str | tuple[CommandResults, dict[str, Any]] | CommandResults:
        results = BuilderCommandResponses(
            self.requested_method,
            self.client,
            self.args,
            self.first_fetch,
            self.max_requests,
        ).build()
        return results

    def change_violation_status(self) -> str | tuple[CommandResults, dict[str, Any]] | CommandResults:
        results = BuilderCommandResponses(
            self.requested_method,
            self.client,
            self.args,
            self.first_fetch,
            self.max_requests,
        ).build()
        return results

    def test_module(self) -> str:
        response = self.client.get_formatted_brands()
        if isinstance(response, list) and len(response) > 0:
            return "ok"
        return "Test failed, some problems with getting brands."

    def fetch_incidents(
        self,
    ) -> tuple[dict[str, int | Any], list]:
        next_run, violations = IncidentBuilder(
            client=self.client,
            last_run=self.last_run,
            first_fetch_time=self.first_fetch,
            max_requests=self.max_requests,
            violation_subtypes=self.violation_subtypes,
            violation_section=self.violation_section,
            brands=self.brands,
            download_images=self.download_images,
            only_typosquatting=self.only_typosquatting,
        ).build()
        return next_run, violations

    @staticmethod
    def get_avalible_commands():
        """
        Returns a list of available commands.
        Adds the prefix `gibdrp-` to all methods except `test_module` and `fetch_incidents`.
        """

        def format_method(method_name: str) -> str | None:
            # Exclude magic methods and the get_avalible_commands and get_results functions themselves
            if method_name.startswith("__") or method_name in (
                "get_avalible_commands",
                "get_results",
            ):
                return None
            # Exceptions: methods without a prefix but with hyphens
            if method_name in ["test_module", "fetch_incidents"]:
                return method_name.replace("_", "-")
            # For the others, add a prefix and replace `_` with `-`
            return f"gibdrp-{method_name.replace('_', '-')}"

        # Get and format all methods
        methods = [format_method(method) for method in dir(Commands) if callable(getattr(Commands, method))]
        return list(filter(None, methods))

    def get_results(
        self,
    ) -> (
        tuple[CommandResults, str]
        | tuple[tuple[CommandResults, tuple[dict, list]], str]
        | tuple[str, str]
        | tuple[tuple[dict[str, int | Any], list], str]
    ):
        # Check if the method exists in the class
        if hasattr(self, self.requested_method) and callable(getattr(self, self.requested_method)):
            # Call the method
            return getattr(self, self.requested_method)(), self.requested_method
        else:
            raise AttributeError(f"Command {self.command} is not implemented.")


def main():
    """
    PARSE AND VALIDATE INTEGRATION PARAMS
    """
    try:
        params = demisto.params()
        args = demisto.args()
        command = demisto.command()
        username, api_token = (
            params.get("credentials", {}).get("identifier", ""),
            params.get("credentials", {}).get("password", ""),
        )
        base_url = str(params.get("url"))
        proxy = params.get("proxy", False)
        verify_certificate = not params.get("insecure", False)
        first_fetch = params.get("first_fetch", "3 days").strip()
        max_requests = int(params.get("max_fetch", 3))
        # violation_subtypes = params.get("violationSubtypes") # At the moment this
        # filtering moment is not valid,
        # it can be used once the cyberintegrations library is updated
        violation_subtypes = None
        violation_section = params.get("violationSection")
        # Currently all brands are transferred, but filtering will only happen on the very
        # first brand because of the filtering in the library,
        # but after time it is planned to update the library and then it will be possible
        # to filter on multiple brands
        brands = params.get("brands")
        download_images = params.get("download_images", False)
        only_typosquatting = params.get("only_typosquatting", False)

        client = Client(
            base_url=base_url,
            verify=verify_certificate,
            auth=(username, api_token),
            proxy=proxy,
        )
        # All available commands are described here, but this
        # particular array is not used in the code in any way,
        # because the commands are implemented flexibly and
        # such a record of them is required only for passing the tests
        if command in [
            "test-module",
            "gibdrp-get-brands",
            "gibdrp-get-subscriptions",
            "gibdrp-get-violation-by-id",
            "gibdrp-change-violation-status",
        ]:
            pass

        if command not in Commands.get_avalible_commands():
            raise Exception(f"{command} invalid")

        results, requested_method = Commands(
            client=client,
            command=command,
            args=args,
            first_fetch=first_fetch,
            max_requests=max_requests,
            violation_subtypes=violation_subtypes,
            violation_section=violation_section,
            brands=brands,
            download_images=download_images,
            only_typosquatting=only_typosquatting,
        ).get_results()
        if requested_method in Commands.methods_requiring_return_results:
            return_results(results)
        else:
            if isinstance(results, tuple) and len(results) == 2:
                next_run, violations = results
                demisto.setLastRun(next_run)
                demisto.incidents(violations)
            else:
                raise ValueError("Expected results to be a tuple containing next_run and violations")

    except Exception:
        return_error(f"Failed to execute {demisto.command()} command.\n" f"Error: {format_exc()}")


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