Group-IB Threat Intelligence & Attribution Feed

Use Group-IB Threat Intelligence Feed integration to fetch IOCs from various Group-IB collections.

Data Enrichment & Threat Intelligence · Group-IB Threat Intelligence · Feed

Details

IDGroup-IB Threat Intelligence & Attribution Feed
ProviderGroup IB
CategoryData Enrichment & Threat Intelligence
From Version6.0.0
Docker Imagedemisto/vendors-sdk:1.0.0.10120494
Supported ModulesAgentix XSIAM

README

Group-IB Threat Intelligence Feed

Use Group-IB Threat Intelligence Feed integration to fetch IOCs (Indicators of Compromise) from various Group-IB collections. The integration supports multiple collections - see the Data Collections Overview section below for the complete list with descriptions and recommended date ranges (indicator first fetch).

Prerequisites

  1. Access Group-IB Threat Intelligence (TI) Web Interface
  2. Generate API Credentials
    • In the web interface, click your name in the upper right corner
    • Select ProfileSecurity and Access tab
    • Click Personal token and follow the instructions to generate your API token
    • Note: The API token serves as your password for authentication
  3. Network Configuration
    • Important: Contact Group-IB support to add your Cortex XSOAR server’s IP address to the allow list
    • If you are using a proxy, provide the public IP address of the proxy server instead
    • Make sure you have added Group-IB API IPs/URLs to your FW/Proxy rules.

Important Notes

Limit Parameter

The Limit (items per request) parameter specifies the number of records requested per API page. This limit applies to all collections configured in the integration instance.

Important considerations:

  • The limit determines how many records are fetched in a single API request. For example, if “Number of requests per collection” is set to 2 and the limit is 100, the integration will make 2 requests per collection, each requesting up to 100 records, resulting in up to 200 records per collection per fetch cycle.
  • Different collections may have different optimal limit values based on their data structure and API recommendations. We strongly recommend consulting the official API Limitations documentation for specific limit recommendations for each collection.
  • Best practice: Create separate integration instances for different collections or groups of collections that share similar optimal limit values. This allows you to optimize performance for each collection type.

Data Collections Overview

Once the configuration is complete, the following collections become available in Cortex XSOAR. For detailed information about each collection, its structure, and available fields, please refer to the official Collections Details documentation.

Note: If you’re using a POC or partner license, access to data is limited to 30 days. The recommended date ranges below are guidelines and can be adjusted according to your needs.

Collection Description Recommended Date Range
compromised/account_group In your compromised accounts, there CNCs in place which can be used as IOCs. Usually included in IOC Common. 2-4 years
compromised/bank_card_group In your compromised cards, there CNCs in place which can be used as IOCs. Usually included in IOC Common. 2 years
compromised/masked_card In masked card records, top-level CNC domain and IP values can be used as IOCs similarly to other compromised card collections. 2 years
compromised/mule Information on compromised accounts used by threat actors for money laundering and fund transfers. Collection is currently deprecated - only legacy information is available 90 days
attacks/ddos Data on Distributed Denial of Service (DDoS) attacks, including targeted resources and attack durations. 5-10 days
attacks/deface Records of defacement attacks, highlighting compromised websites and related actors. 5-10 days
attacks/phishing_group Information on phishing attacks, including URLs of phishing websites. Note: Do not use IPs for detection - it may cause many false positives. Focus only on URLs. 3-5 days
attacks/phishing_kit Collections of phishing website templates, scripts, and configurations used by attackers. 30 days
apt/threat IOCs only from APT reports. 2-4 years
hi/threat IOCs only from Cybercriminals reports. 2-4 years
ioc/common General indicators of Compromise (IoCs) from threat reports (Cybercriminals and APT) and Malware sections. Consists of Hashes (MD5, SHA1, SHA256), IPs, domains and URLs. Major source of IOCs. Contains: malware/malware, malware/cnc, hi/threat, apt/threat, hi/threat_actor, apt/threat_actor. 90 days
malware/cnc Information on malware Command-and-Control (C&C) servers used for data exfiltration and command distribution. This feed is also part of IOC Common. 90 days
osi/vulnerability Information on software vulnerabilities, associated exploits, and available proof-of-concept details. 90 days
suspicious_ip/tor_node Data about known Tor exit nodes used as anonymity relays. 5 days
suspicious_ip/open_proxy Information on publicly available proxy servers, including potentially misconfigured proxies. 5 days
suspicious_ip/scanner IP addresses identified as scanning or probing corporate networks. 5 days
suspicious_ip/socks_proxy IP addresses of infected hosts configured as SOCKS proxies used for anonymized attacks. 5 days
suspicious_ip/vpn Information about public and private VPN servers identified as potentially malicious or suspicious. 5 days

Configure Group-IB Threat Intelligence Feed in Cortex

Parameter Description Required
GIB TI URL The FQDN/IP the integration should connect to (default: https://tap.group-ib.com/api/v2/). True
Username Enter the email address you use to log into the web interface. The API token serves as your password for authentication. True
Trust any certificate (not secure) Whether to allow connections without verifying SSL certificates validity. False
Use system proxy settings Whether to use XSOAR system proxy settings to connect to the API. False
Fetches indicators Enable to fetch indicators from the feed (default: enabled). False
Indicator Reputation Select the default reputation for indicators from this feed (default: Suspicious). Options: Unknown, Benign, Suspicious, Malicious. As an example, it is recommended to use Malicious for IOC common and Suspicious for Suspicious IP collections. False
Source Reliability Select the reliability rating for the source (required, default: A - Completely reliable). Options: A - Completely reliable, B - Usually reliable, C - Fairly reliable, D - Not usually reliable, E - Unreliable, F - Reliability cannot be judged. True
Feed Fetch Interval Configure how often to fetch indicators (hours and minutes, default: 1 minute). False
Bypass exclusion list When enabled, bypasses the exclusion list for indicators from this feed. This means that if an indicator from this feed is on the exclusion list, the indicator might still be added to the system. False
Indicator collections Select the collections you want to fetch indicators from. Read more about collections here. False
Indicator first fetch Specify the date range for initial data fetch (default: “3 days”). False
Number of requests per collection Number of API requests per collection in each fetch iteration (default: 2). Each request picks up to 100 (limit) objects with different amount of indicators. If you face runtime errors, lower the value. False
Limit (items per request) Specifies the number of records fetched per API request (default: 100). This limit applies to all collections in the instance. For optimal performance, check the official API Limitations documentation for recommended limit values per collection. Best practice: create separate integration instances for different collections or groups of collections with similar optimal limit values. False
Tags Enter tags for indicators if needed. False
Traffic Light Protocol Color Select the Traffic Light Protocol (TLP) designation to apply to indicators fetched from the feed. Options: RED, AMBER, GREEN, WHITE. When Use TLP from source is disabled, this value is applied to all indicators. When Use TLP from source is enabled, this value is used only as a fallback when Group-IB does not provide TLP for an indicator. False
Use TLP from source (per indicator) When enabled, each indicator gets its TLP from Group-IB when the source provides it (see TLP per indicator for the list of collections). For ioc/common, TLP is always set to AMBER. The Traffic Light Protocol Color setting is then used only as a fallback when the source has no TLP. When disabled, all indicators use the single Traffic Light Protocol Color selected above. False
Indicator Expiration Method Configure how indicators expire. Options: Time Interval, Never Expire, When removed from the feed. False

Additional Resources

For detailed information about collections, their structure, available fields, and recommended date ranges, refer to the official Collections Details documentation.

For step-by-step configuration instructions including classifier and mapper setup, refer to the integration description file.

Traffic Light Protocol (TLP) per indicator

By default, the integration applies a single Traffic Light Protocol Color to all indicators (the one selected in the integration settings). If you want each indicator to keep the TLP value provided by Group-IB for that record, enable Use TLP from source (per indicator).

When Use TLP from source is enabled:

  • Collections that receive TLP from Group-IB (when the source provides it): compromised/account_group, compromised/bank_card_group, compromised/masked_card, attacks/ddos, attacks/deface, attacks/phishing_kit, attacks/phishing_group, apt/threat, hi/threat, osi/vulnerability, osi/git_repository, suspicious_ip/tor_node, suspicious_ip/open_proxy, suspicious_ip/socks_proxy, suspicious_ip/vpn, suspicious_ip/scanner.
  • ioc/common: TLP is always set to AMBER (Group-IB does not provide TLP for this collection).
  • If Group-IB does not provide TLP for an indicator, the Traffic Light Protocol Color setting is used as fallback for that indicator. So the integration-level TLP applies only when the source has no TLP for the given record.

When Use TLP from source is disabled: All indicators receive the same TLP from the Traffic Light Protocol Color setting.

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.

gibti-get-indicators


Get a limited count of indicators for a specified collection and get all indicators from particular events by ID.

Base Command

gibti-get-indicators

Legacy alias gibtia-get-indicators remains available for backward compatibility.

Input

Argument Name Description Required
collection GIB Collection to get indicators from. Possible values are: compromised/mule, compromised/masked_card, compromised/imei, attacks/ddos, attacks/deface, attacks/phishing, attacks/phishing_kit, hi/threat, apt/threat, osi/vulnerability, suspicious_ip/tor_node, suspicious_ip/open_proxy, suspicious_ip/socks_proxy, malware/cnc. Required
id Incident ID to get indicators. If set, all indicators will be provided from the particular incident. Optional
limit Limit of indicators to display in War Room. Possible values are: 10, 20, 30, 40, 50. Default is 50. Optional

Command Example

!gibti-get-indicators collection=ioc/common

Configuration parameters

  • url — GIB TI URL (required)
  • credentials — Username (required)
  • insecure — Trust any certificate (not secure)
  • proxy — Use system proxy settings
  • feedIncremental — Incremental feed
  • feed — Fetch indicators
  • feedReputation — Indicator Reputation
  • feedReliability — Source Reliability (required)
  • feedFetchInterval — Feed Fetch Interval
  • feedBypassExclusionList — Bypass exclusion list
  • indicator_collections — Indicator collections
  • indicators_first_fetch — Indicator first fetch
  • requests_count — Number of requests per collection
  • feedTags — Tags
  • tlp_color — Traffic Light Protocol Color
  • use_tlp_from_source — Use TLP from source (per indicator)
  • limit — Limit (items per request)
  • feedExpirationPolicy
  • feedExpirationInterval

Commands (1)

  • gibtia-get-indicators

    Get limited count of indicators for specified collection and get all indicators from particular events by id.

import pytest
import os
from json import load
from typing import Any, cast
from GroupIB_TIA_Feed import (
    fetch_indicators_command,
    Client,
    main,
    DateHelper,
    validate_launch_get_indicators_command,
    _validate_indicator_collections,
    get_indicators_command,
    IndicatorBuilding,
    COMMON_MAPPING,
)
import GroupIB_TIA_Feed
from CommonServerPython import DemistoException
from urllib3.exceptions import InsecureRequestWarning
from urllib3 import disable_warnings as urllib3_disable_warnings
from cyberintegrations.cyberintegrations import Parser

# Disable insecure warnings
urllib3_disable_warnings(InsecureRequestWarning)

COLLECTION_NAMES = [
    "compromised/account_group",
    "compromised/bank_card_group",
    "compromised/mule",
    "attacks/ddos",
    "attacks/deface",
    "attacks/phishing_kit",
    "attacks/phishing_group",
    "apt/threat",
    "hi/threat",
    "suspicious_ip/tor_node",
    "suspicious_ip/open_proxy",
    "suspicious_ip/socks_proxy",
    "suspicious_ip/vpn",
    "suspicious_ip/scanner",
    "malware/cnc",
    "osi/vulnerability",
    "osi/git_repository",
    "ioc/common",
]

realpath = os.path.join(os.path.dirname(os.path.realpath(__file__)))

with open(f"{realpath}/test_data/avalible_collections_example.json") as example:
    AVALIBLE_COLLECTIONS_RAW_JSON = load(example)

with open(f"{realpath}/test_data/main_collections_examples.json") as example:
    COLLECTIONS_RAW_JSON = load(example)


@pytest.fixture(scope="function", params=COLLECTION_NAMES)
def session_fixture(request):
    """
    Fixture for setting up a session with a client instance specific to each collection.

    Given:
      - COLLECTION_NAMES, a list of collection names representing different data types
        that the integration handles.

    When:
      - Each test function uses this fixture to set up a unique session with a particular
        collection name.

    Then:
      - Returns a tuple containing:
          - The current collection name as a parameter for test functions that may need it.
          - An instance of Client configured with the specified base URL, authentication,
            and necessary headers for the integration.
      - This fixture allows parameterized tests that run independently for each collection,
        providing an isolated client setup for each run.
    """
    return request.param, Client(
        base_url="https://some-url.com",
        auth=("example@roup-ib.com", "exampleAPI_TOKEN"),
        verify=True,
        headers={"Accept": "*/*"},
    )


def test_main_error():
    """
    Test for verifying the error-handling behavior in the main() function.

    Given:
      - A main() function configured to raise an exception when calling error_command.

    When:
      - The main function invokes error_command(), which is expected to trigger an error.

    Then:
      - Ensures that a SystemExit exception is raised as expected.
      - The test checks that the main function handles errors in a predictable and controlled
        manner, allowing graceful exits during failure.
    """
    with pytest.raises(SystemExit):
        main()["error_command"]()  # type: ignore


def test_common_mapping_contains_masked_card():
    mapping = cast(dict[str, Any], COMMON_MAPPING["compromised/masked_card"])

    assert mapping["types"]["cnc_domain"] == "Domain"
    assert mapping["types"]["cnc_ipv4_ip"] == "IP"
    assert mapping["parser_mapping"]["cnc_domain"] == "cnc.domain"
    assert mapping["parser_mapping"]["cnc_ipv4_ip"] == "cnc.ipv4.ip"
    assert mapping["parser_mapping"]["evaluation_tlp"] == "evaluation.tlp"


def test_fetch_indicators_command(mocker, session_fixture):
    """
    Test for validating the functionality of fetch_indicators_command with multiple collection types.

    Given:
      - A session_fixture that supplies a client instance configured for a specific collection
        name for each test iteration.
      - collection_name, the current collection name being tested (e.g., "compromised/mule").

    When:
      - The fetch_indicators_command() function is called with:
          - An empty last_run dictionary to indicate that this is the initial data fetch.
          - first_fetch_time set based on specific collection conditions:
            - For "compromised/mule", first_fetch_time is set to a fixed date of "2023-01-01".
            - For "attacks/deface", first_fetch_time is set to "2024-10-01".
            - For all other collections, first_fetch_time is set to "15 days" as a general
              recent timeframe.
          - indicator_collections set to a list containing only the current collection_name.
          - requests_count set to 3, which limits the number of requests per fetch.
          - common_fields set to an empty dictionary for simplicity, as no specific common
            fields are required for this test.

    Then:
      - Validates that:
          - "last_fetch" is a key in next_run, indicating that the command updates last_run
            data with the latest fetch time.
          - The first indicator in the indicators list contains a "fields" dictionary with
            a "gibid" key, verifying that each indicator has the expected structure.
      - This test ensures that fetch_indicators_command retrieves data according to each
        collection's parameters and formats the output consistently.
    """
    collection_name, client = session_fixture
    if collection_name == "compromised/mule":
        first_fetch_time = "2023-01-01"
    elif collection_name == "attacks/deface":
        first_fetch_time = "2024-10-01"
    else:
        first_fetch_time = "15 days"

    mocker.patch.object(client, "get_available_collections_cached", return_value=frozenset(AVALIBLE_COLLECTIONS_RAW_JSON))
    mocker.patch.object(
        client,
        "create_update_generator_proxy_functions",
        return_value=[Parser(chunk=COLLECTIONS_RAW_JSON[collection_name], keys=[], iocs_keys=[])],
    )

    next_run, indicators = fetch_indicators_command(
        client=client,
        last_run={},
        first_fetch_time=first_fetch_time,
        indicator_collections=[collection_name],
        requests_count=3,
        common_fields={},
    )

    assert "last_fetch" in next_run, "Expected 'last_fetch' key in next_run to indicate the last data retrieval time."
    if len(indicators) > 0:
        assert "gibid" in indicators[0].get("fields"), (
            "Expected 'gibid' field in the first indicator's 'fields' dictionary, ensuring each indicator "
            "includes unique identifier data."
        )


def test_integration_test_module_success(mocker):
    """
    Test for verifying successful test_module execution when collections are available.

    Given:
      - A client instance with mocked get_available_collections_cached that returns a non-empty frozenset.

    When:
      - test_module() is called with the client.

    Then:
      - Returns 'ok' indicating successful connection and availability of collections.
    """
    client = Client(
        base_url="https://some-url.com",
        auth=("example@group-ib.com", "exampleAPI_TOKEN"),
        verify=True,
        headers={"Accept": "*/*"},
    )
    mocker.patch.object(client, "get_available_collections_cached", return_value=frozenset({"collection1", "collection2"}))

    result = GroupIB_TIA_Feed.test_module(client)

    assert result == "ok", "Expected 'ok' when collections are available."


def test_integration_test_module_no_collections(mocker):
    """
    Test for verifying test_module behavior when no collections are available.

    Given:
      - A client instance with mocked get_available_collections_cached that returns an empty frozenset.

    When:
      - test_module() is called with the client.

    Then:
      - Returns a message indicating that no collections are available.
    """
    client = Client(
        base_url="https://some-url.com",
        auth=("example@group-ib.com", "exampleAPI_TOKEN"),
        verify=True,
        headers={"Accept": "*/*"},
    )
    mocker.patch.object(client, "get_available_collections_cached", return_value=frozenset())

    result = GroupIB_TIA_Feed.test_module(client)

    assert result == "There are no collections available", "Expected message when no collections are available."


def test_date_helper_first_time_fetch():
    """
    Test for verifying DateHelper.handle_first_time_fetch behavior on first fetch.

    Given:
      - An empty last_run dictionary indicating first-time fetch.
      - A valid first_fetch_time string.

    When:
      - DateHelper.handle_first_time_fetch() is called with these parameters.

    Then:
      - Returns date_from as a formatted date string and seq_update as None.
    """
    last_run: dict[str, Any] = {}
    collection_name = "compromised/account_group"
    first_fetch_time = "2023-01-01"

    date_from, seq_update = DateHelper.handle_first_time_fetch(last_run, collection_name, first_fetch_time)

    assert date_from == "2023-01-01", "Expected date_from to be formatted as YYYY-MM-DD."
    assert seq_update is None, "Expected seq_update to be None on first fetch."


def test_date_helper_subsequent_fetch():
    """
    Test for verifying DateHelper.handle_first_time_fetch behavior on subsequent fetches.

    Given:
      - A last_run dictionary with existing last_fetch data for the collection.
      - A first_fetch_time string.

    When:
      - DateHelper.handle_first_time_fetch() is called with these parameters.

    Then:
      - Returns date_from as None and seq_update as the value from last_run.
    """
    last_run = {"last_fetch": {"compromised/account_group": 12345}}
    collection_name = "compromised/account_group"
    first_fetch_time = "15 days"

    date_from, seq_update = DateHelper.handle_first_time_fetch(last_run, collection_name, first_fetch_time)

    assert date_from is None, "Expected date_from to be None on subsequent fetch."
    assert seq_update == 12345, "Expected seq_update to match the value from last_run."


def test_date_helper_invalid_first_fetch_time():
    """
    Test for verifying DateHelper.handle_first_time_fetch raises exception on invalid date format.

    Given:
      - An empty last_run dictionary.
      - An invalid first_fetch_time string that cannot be parsed.

    When:
      - DateHelper.handle_first_time_fetch() is called with these parameters.

    Then:
      - Raises DemistoException with an appropriate error message.
    """
    last_run: dict[str, Any] = {}
    collection_name = "compromised/account_group"
    first_fetch_time = "invalid-date-format"

    with pytest.raises(DemistoException, match="Inappropriate indicators_first_fetch format"):
        DateHelper.handle_first_time_fetch(last_run, collection_name, first_fetch_time)


def test_validate_launch_get_indicators_command_valid_input():
    """
    Test for verifying validate_launch_get_indicators_command with valid inputs.

    Given:
      - A valid limit (integer between 1 and 50).
      - A valid collection name that exists in COMMON_MAPPING.

    When:
      - validate_launch_get_indicators_command() is called with these parameters.

    Then:
      - Does not raise any exception, indicating successful validation.
    """
    limit = 25
    collection_name = "compromised/account_group"

    # Should not raise any exception
    validate_launch_get_indicators_command(limit, collection_name)


def test_validate_launch_get_indicators_command_invalid_limit_type():
    """
    Test for verifying validate_launch_get_indicators_command raises exception for non-numeric limit.

    Given:
      - A limit that is not a number (string that cannot be converted to int).

    When:
      - validate_launch_get_indicators_command() is called with this limit.

    Then:
      - Raises DemistoException with message "Limit should be a number."
    """
    limit = "not-a-number"
    collection_name = "compromised/account_group"

    with pytest.raises(DemistoException, match="Limit should be a number"):
        validate_launch_get_indicators_command(limit, collection_name)


def test_validate_launch_get_indicators_command_limit_too_low():
    """
    Test for verifying validate_launch_get_indicators_command raises exception for limit <= 0.

    Given:
      - A limit that is 0 or negative.

    When:
      - validate_launch_get_indicators_command() is called with this limit.

    Then:
      - Raises DemistoException with message "Limit should be greater than 0."
    """
    limit = 0
    collection_name = "compromised/account_group"

    with pytest.raises(DemistoException, match="Limit should be greater than 0"):
        validate_launch_get_indicators_command(limit, collection_name)


def test_validate_launch_get_indicators_command_limit_too_high():
    """
    Test for verifying validate_launch_get_indicators_command raises exception for limit > 50.

    Given:
      - A limit that exceeds 50.

    When:
      - validate_launch_get_indicators_command() is called with this limit.

    Then:
      - Raises DemistoException with message "Limit should be lower than or equal to 50."
    """
    limit = 51
    collection_name = "compromised/account_group"

    with pytest.raises(DemistoException, match="Limit should be lower than or equal to 50"):
        validate_launch_get_indicators_command(limit, collection_name)


def test_validate_launch_get_indicators_command_invalid_collection():
    """
    Test for verifying validate_launch_get_indicators_command raises exception for invalid collection name.

    Given:
      - A collection name that does not exist in COMMON_MAPPING.

    When:
      - validate_launch_get_indicators_command() is called with this collection name.

    Then:
      - Raises DemistoException with message about incorrect collection name.
    """
    limit = 25
    collection_name = "invalid/collection"

    with pytest.raises(DemistoException, match="Incorrect collection name"):
        validate_launch_get_indicators_command(limit, collection_name)


def test_validate_indicator_collections_success(mocker):
    """
    Test for verifying _validate_indicator_collections succeeds when every requested
    collection is granted to the API user.

    Given:
      - A client whose `get_available_collections_cached` returns a frozenset of
        granted collections that fully covers the requested list.

    When:
      - _validate_indicator_collections() is called with a non-empty subset of the
        granted collections.

    Then:
      - Does not raise any exception.
      - Calls `get_available_collections_cached` exactly once (no extra round-trips).
    """
    client = Client(
        base_url="https://some-url.com",
        auth=("example@group-ib.com", "exampleAPI_TOKEN"),
        verify=True,
        headers={"Accept": "*/*"},
    )
    granted = frozenset({"compromised/account_group", "attacks/ddos", "other/collection"})
    cached_mock = mocker.patch.object(client, "get_available_collections_cached", return_value=granted)

    _validate_indicator_collections(client, ["compromised/account_group", "attacks/ddos"])

    assert cached_mock.call_count == 1


def test_validate_indicator_collections_failure(mocker):
    """
    Test for verifying _validate_indicator_collections raises a DemistoException when
    at least one requested collection is not granted to the API user.

    Given:
      - A client whose `get_available_collections_cached` returns a frozenset that
        does NOT include some of the requested collections.

    When:
      - _validate_indicator_collections() is called with a list that contains an
        unavailable collection.

    Then:
      - Raises DemistoException whose message names the offending collection so the
        operator can immediately fix the instance settings.
    """
    client = Client(
        base_url="https://some-url.com",
        auth=("example@group-ib.com", "exampleAPI_TOKEN"),
        verify=True,
        headers={"Accept": "*/*"},
    )
    mocker.patch.object(client, "get_available_collections_cached", return_value=frozenset({"other/collection"}))

    with pytest.raises(DemistoException, match="unavailable/collection"):
        _validate_indicator_collections(client, ["unavailable/collection"])


def test_validate_indicator_collections_empty_list_skips_remote_call(mocker):
    """
    Test for verifying _validate_indicator_collections is a no-op for an empty list.

    Given:
      - A client whose `get_available_collections_cached` is patched to fail loudly
        if it is ever invoked.

    When:
      - _validate_indicator_collections() is called with an empty list.

    Then:
      - Returns silently, never touching the network. This protects fetch flows
        configured with no collections from triggering an unnecessary
        `/user/granted_collections` round-trip.
    """
    client = Client(
        base_url="https://some-url.com",
        auth=("example@group-ib.com", "exampleAPI_TOKEN"),
        verify=True,
        headers={"Accept": "*/*"},
    )
    cached_mock = mocker.patch.object(
        client,
        "get_available_collections_cached",
        side_effect=AssertionError("get_available_collections_cached must not be called for empty selection"),
    )

    _validate_indicator_collections(client, [])

    cached_mock.assert_not_called()


def test_indicator_building_clean_data():
    """
    Test for verifying IndicatorBuilding.clean_data removes None, empty values, and flattens nested lists.

    Given:
      - A list of dictionaries containing None values, empty strings, empty lists, and nested lists.

    When:
      - IndicatorBuilding.clean_data() is called with this data.

    Then:
      - Returns cleaned data with None, empty strings, and empty lists removed, and nested lists flattened.
    """
    data = [
        {"key1": "value1", "key2": None, "key3": "", "key4": []},
        {"key1": ["nested", "list"], "key2": [["deeply", "nested"], "value"]},
        {"key1": "value2", "key2": [None, "", "valid"]},
    ]

    cleaned = IndicatorBuilding.clean_data(data)

    assert len(cleaned) == 3, "Expected all items to be preserved."
    # clean_data doesn't remove keys with None values, only cleans lists
    assert cleaned[0]["key2"] is None, "None values are preserved in dict keys."
    assert cleaned[0]["key4"] == [], "Empty lists are preserved."
    assert cleaned[1]["key1"] == ["nested", "list"], "Nested lists are flattened."
    assert cleaned[2]["key2"] == ["valid"], "None and empty values are removed from lists."


def test_indicator_building_extract_single_value():
    """
    Test for verifying IndicatorBuilding.extract_single_value extracts non-empty values from nested structures.

    Given:
      - An IndicatorBuilding instance.
      - Various nested list structures containing None, empty strings, and valid values.

    When:
      - extract_single_value() is called with these structures.

    Then:
      - Returns the first non-empty, non-None value found, or None if no valid value exists.
    """
    builder = IndicatorBuilding(
        parsed_json=[],
        collection_name="test",
        common_fields={},
        collection_mapping={},
    )

    assert builder.extract_single_value([None, "", "valid"]) == "valid", "Expected first valid value."
    assert builder.extract_single_value([[None, "nested"], "value"]) == "nested", "Expected nested valid value."
    assert builder.extract_single_value([None, "", []]) is None, "Expected None when no valid value exists."
    assert builder.extract_single_value("simple") == "simple", "Expected simple value to be returned as-is."


def test_indicator_building_find_iocs_in_feed():
    """
    Test for verifying IndicatorBuilding.find_iocs_in_feed correctly extracts IOCs from feed data.

    Given:
      - An IndicatorBuilding instance with a valid collection mapping.
      - A feed dictionary containing IOC data matching the collection mapping.

    When:
      - find_iocs_in_feed() is called with the feed data.

    Then:
      - Returns a list of indicators with proper structure including value, type, rawJSON, and fields.
    """
    collection_name = "ioc/common"
    mapping = COMMON_MAPPING[collection_name]
    feed = {
        "id": "test-id-123",
        "url": "https://example.com/malicious",
        "domain": "malicious.example.com",
        "ip": "192.168.1.1",
        "dateFirstSeen": "2023-01-01T00:00:00Z",
        "dateLastSeen": "2023-01-02T00:00:00Z",
    }

    builder = IndicatorBuilding(
        parsed_json=[],
        collection_name=collection_name,
        common_fields={"trafficlightprotocol": "RED"},
        collection_mapping=mapping,
    )

    indicators = builder.find_iocs_in_feed(feed)

    assert len(indicators) > 0, "Expected at least one indicator to be extracted."
    assert all("value" in ind for ind in indicators), "Expected all indicators to have 'value' field."
    assert all("type" in ind for ind in indicators), "Expected all indicators to have 'type' field."
    assert all("rawJSON" in ind for ind in indicators), "Expected all indicators to have 'rawJSON' field."
    assert all("fields" in ind for ind in indicators), "Expected all indicators to have 'fields' field."


def test_fetch_indicators_command_with_last_run(mocker, session_fixture):
    """
    Test for verifying fetch_indicators_command behavior when last_run contains previous fetch data.

    Given:
      - A session_fixture providing a client and collection name.
      - A last_run dictionary with existing last_fetch data for the collection.

    When:
      - fetch_indicators_command() is called with this last_run data.

    Then:
      - Uses seq_update from last_run instead of date_from.
      - Returns updated next_run with the latest seq_update.
    """
    collection_name, client = session_fixture
    last_run = {"last_fetch": {collection_name: 12345}}
    first_fetch_time = "15 days"

    mocker.patch.object(client, "get_available_collections_cached", return_value=frozenset(AVALIBLE_COLLECTIONS_RAW_JSON))
    mock_parser = Parser(chunk=COLLECTIONS_RAW_JSON[collection_name], keys=[], iocs_keys=[])
    mock_parser.sequpdate = 67890
    mocker.patch.object(
        client,
        "create_update_generator_proxy_functions",
        return_value=[mock_parser],
    )

    next_run, indicators = fetch_indicators_command(
        client=client,
        last_run=last_run,
        first_fetch_time=first_fetch_time,
        indicator_collections=[collection_name],
        requests_count=1,
        common_fields={},
    )

    assert "last_fetch" in next_run, "Expected 'last_fetch' key in next_run."
    assert collection_name in next_run["last_fetch"], "Expected collection name in next_run['last_fetch']."
    assert next_run["last_fetch"][collection_name] == 67890, "Expected seq_update to be updated from parser."


def test_fetch_indicators_command_multiple_collections(mocker):
    """
    Test for verifying fetch_indicators_command handles multiple collections correctly.

    Given:
      - A client instance.
      - A list of multiple collection names to fetch.

    When:
      - fetch_indicators_command() is called with multiple collections.

    Then:
      - Processes all collections and returns next_run with last_fetch for each collection.
    """
    client = Client(
        base_url="https://some-url.com",
        auth=("example@group-ib.com", "exampleAPI_TOKEN"),
        verify=True,
        headers={"Accept": "*/*"},
    )

    collections = ["compromised/account_group", "attacks/ddos"]
    mocker.patch.object(client, "get_available_collections_cached", return_value=frozenset(AVALIBLE_COLLECTIONS_RAW_JSON))

    def create_mock_parser(collection_name):
        """Helper function to create a mock parser for a specific collection."""
        mock_parser = Parser(chunk=COLLECTIONS_RAW_JSON[collection_name], keys=[], iocs_keys=[])
        mock_parser.sequpdate = 10000
        return mock_parser

    def side_effect_func(**kwargs):
        """Side effect function that returns appropriate parser based on collection_name."""
        collection_name = kwargs.get("collection_name")
        return [create_mock_parser(collection_name)]

    mocker.patch.object(
        client,
        "create_update_generator_proxy_functions",
        side_effect=side_effect_func,
    )

    next_run, indicators = fetch_indicators_command(
        client=client,
        last_run={},
        first_fetch_time="15 days",
        indicator_collections=collections,
        requests_count=1,
        common_fields={},
    )

    assert "last_fetch" in next_run, "Expected 'last_fetch' key in next_run."
    assert len(next_run["last_fetch"]) == len(collections), "Expected last_fetch entry for each collection."


def test_get_indicators_command_without_id(mocker, session_fixture):
    """
    Test for verifying get_indicators_command behavior when fetching without specific ID.

    Given:
      - A session_fixture providing a client and collection name.
      - Command arguments without an ID parameter.

    When:
      - get_indicators_command() is called with these arguments.

    Then:
      - Returns a list of CommandResults objects with readable output.
    """
    collection_name, client = session_fixture
    args = {"collection": collection_name, "limit": 10}

    mocker.patch.object(
        client,
        "create_update_generator_proxy_functions",
        return_value=[Parser(chunk=COLLECTIONS_RAW_JSON[collection_name], keys=[], iocs_keys=[])],
    )

    results = get_indicators_command(client, args)

    assert isinstance(results, list), "Expected results to be a list."
    # Some collections may not have indicators in test data, so we check structure if results exist
    if len(results) > 0:
        assert all(hasattr(r, "readable_output") for r in results), "Expected all results to have readable_output."