CSVFeedApiModule

Common code that will be appended into each CSV feed integration when it's deployed

python · ApiModules

Source

import demistomock as demisto
from CommonServerPython import *

from CommonServerUserPython import *

""" IMPORTS """
import csv
import gzip
from re import Pattern
from typing import Any

import urllib3

# disable insecure warnings
urllib3.disable_warnings()

# Globals
DATE_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
THRESHOLD_IN_SECONDS = 43200  # 12 hours in seconds


class Client(BaseClient):
    def __init__(
        self,
        url: str,
        feed_url_to_config: dict[str, dict] | None = None,
        fieldnames: str = "",
        insecure: bool = False,
        credentials: dict = None,
        ignore_regex: str = None,
        encoding: str = "latin-1",
        delimiter: str = ",",
        doublequote: bool = True,
        escapechar: str | None = None,
        quotechar: str = '"',
        skipinitialspace: bool = False,
        polling_timeout: int = 20,
        proxy: bool = False,
        feedTags: str | None = None,
        tlp_color: str | None = None,
        value_field: str = "value",
        **kwargs,
    ):
        """
        :param url: URL of the feed.
        :param feed_url_to_config: for each URL, a configuration of the feed that contains
         If *null* the values in the first row of the file are used as names. Default: *null*
         Example:
         feed_url_to_config = {
            'https://ipstack.com':
            {
                'fieldnames': ['value'],
                'indicator_type': 'IP',
                'mapping': {
                    'Date': 'date' / 'Date': ('date', r'(regex_string)', 'The date is {}')
                }
            }
         }
         For the mapping you can use either:
            1. 'indicator_field': 'value_from_feed'
            2. 'indicator_field': ('value_from_feed', regex_string_extractor, string_formatter)
                * regex_string_extractor will extract the first match from the value_from_feed,
                Use None to get the full value of the field.
                * string_formatter will format the data in your preferred way, Use None to get the extracted field.
            3. 'indicator_field': ('value_from_feed', 'field_mapper_function')
                * field_mapper_function will accept as an argument 'value_from_feed' and return the data
                in your preferred way.
        :param fieldnames: list of field names in the file. If *null* the values in the first row of the file are
            used as names. Default: *null*
        :param insecure: boolean, if *false* feed HTTPS server certificate is verified. Default: *false*
        :param credentials: username and password used for basic authentication.
        Can be also used as API key header and value by specifying _header in the username field.
        :param ignore_regex: python regular expression for lines that should be ignored. Default: *null*
        :param encoding: Encoding of the feed, latin-1 by default.
        :param delimiter: see `csv Python module
            <https://docs.python.org/3/library/csv.html#dialects-and-formatting-parameters>`. Default: ,
        :param doublequote: see `csv Python module
            <https://docs.python.org/3/library/csv.html#dialects-and-formatting-parameters>`. Default: true
        :param escapechar: see `csv Python module
            <https://docs.python.org/3/library/csv.html#dialects-and-formatting-parameters>`. Default null
        :param quotechar: see `csv Python module
            <https://docs.python.org/3/library/csv.html#dialects-and-formatting-parameters>`. Default "
        :param skipinitialspace: see `csv Python module
            <https://docs.python.org/3/library/csv.html#dialects-and-formatting-parameters>`. Default False
        :param polling_timeout: timeout of the polling request in seconds. Default: 20
        :param proxy: Sets whether use proxy when sending requests
        :param tlp_color: Traffic Light Protocol color.
        """
        self.tags: list[str] = argToList(feedTags)
        self.tlp_color = tlp_color
        self.value_field = value_field
        if not credentials:
            credentials = {}

        auth: tuple | None = None
        self.headers = {}

        username = credentials.get("identifier", "")
        if username.startswith("_header:"):
            header_name = username.split(":")[1]
            header_value = credentials.get("password", "")
            self.headers[header_name] = header_value
        else:
            password = credentials.get("password", "")
            auth = None
            if username and password:
                auth = (username, password)

        super().__init__(base_url=url, proxy=proxy, verify=not insecure, auth=auth)

        try:
            self.polling_timeout = int(polling_timeout)
        except (ValueError, TypeError):
            return_error('Please provide an integer value for "Request Timeout"')
        self.encoding = encoding
        self.ignore_regex: Pattern | None = None
        if ignore_regex is not None:
            self.ignore_regex = re.compile(ignore_regex)
        self.feed_url_to_config: dict[str, dict] | None = feed_url_to_config
        self.fieldnames = argToList(fieldnames)
        self.dialect: dict[str, Any] = {
            "delimiter": delimiter,
            "doublequote": doublequote,
            "escapechar": escapechar,
            "quotechar": quotechar,
            "skipinitialspace": skipinitialspace,
        }

    def _build_request(self, url):
        r = requests.Request("GET", url, auth=self._auth)

        return r.prepare()

    def build_iterator(self, **kwargs):
        results = []
        urls = self._base_url
        if not isinstance(urls, list):
            urls = [urls]
        for url in urls:
            _session = requests.Session()

            prepreq = self._build_request(url)

            # this is to honour the proxy environment variables
            kwargs.update(
                _session.merge_environment_settings(
                    prepreq.url,
                    {},
                    None,
                    None,
                    None,  # defaults
                )
            )
            kwargs["stream"] = True
            kwargs["verify"] = self._verify
            kwargs["timeout"] = self.polling_timeout

            if is_demisto_version_ge("6.5.0"):
                # Set the If-None-Match and If-Modified-Since headers if we have etag or
                # last_modified values in the context.
                last_run = demisto.getLastRun()
                etag = last_run.get(url, {}).get("etag")
                last_modified = last_run.get(url, {}).get("last_modified")
                last_updated = last_run.get(url, {}).get("last_updated")
                # To avoid issues with indicators expiring, if 'last_updated' is over X hours old,
                # we'll refresh the indicators to ensure their expiration time is updated.
                # For further details, refer to : https://confluence-dc.paloaltonetworks.com/display/DemistoContent/Json+Api+Module     # noqa: E501
                if last_updated and has_passed_time_threshold(timestamp_str=last_updated, seconds_threshold=THRESHOLD_IN_SECONDS):
                    last_modified = None
                    etag = None
                    demisto.debug(
                        "Since it's been a long time with no update, to make sure we are keeping the indicators alive, \
                        we will refetch them from scratch"
                    )

                if etag:
                    self.headers["If-None-Match"] = etag

                if last_modified:
                    self.headers["If-Modified-Since"] = last_modified

            # set request headers
            if "headers" in kwargs:
                self.headers.update(kwargs["headers"])
                del kwargs["headers"]

            if self.headers:
                prepreq.headers.update(self.headers)

            try:
                r = _session.send(prepreq, **kwargs)
            except requests.exceptions.ConnectTimeout as exception:
                err_msg = (
                    "Connection Timeout Error - potential reasons might be that the Server URL parameter"
                    " is incorrect or that the Server is not accessible from your host."
                )
                raise DemistoException(err_msg, exception)
            except requests.exceptions.SSLError as exception:
                # in case the "Trust any certificate" is already checked
                if not self._verify:
                    raise
                err_msg = (
                    "SSL Certificate Verification Failed - try selecting 'Trust any certificate' checkbox in"
                    " the integration configuration."
                )
                raise DemistoException(err_msg, exception)
            except requests.exceptions.ProxyError as exception:
                err_msg = (
                    "Proxy Error - if the 'Use system proxy' checkbox in the integration configuration is"
                    " selected, try clearing the checkbox."
                )
                raise DemistoException(err_msg, exception)
            except requests.exceptions.ConnectionError as exception:
                # Get originating Exception in Exception chain
                error_class = str(exception.__class__)
                err_type = "<" + error_class[error_class.find("'") + 1 : error_class.rfind("'")] + ">"
                err_msg = (
                    "Verify that the server URL parameter"
                    " is correct and that you have access to the server from your host."
                    f"\nError Type: {err_type}\nError Number: [{exception.errno}]\nMessage: {exception.strerror}\n"
                )
                raise DemistoException(err_msg, exception)
            try:
                r.raise_for_status()
            except Exception:
                return_error(f'Exception in request: {r.status_code} {r.content.decode("utf-8")}')
                raise

            response = self.get_feed_content_divided_to_lines(url, r)
            if self.feed_url_to_config:
                fieldnames = self.feed_url_to_config.get(url, {}).get("fieldnames", [])
                skip_first_line = self.feed_url_to_config.get(url, {}).get("skip_first_line", False)
            else:
                fieldnames = self.fieldnames
                skip_first_line = False
            if self.ignore_regex is not None:
                response = filter(  # type: ignore
                    lambda x: self.ignore_regex.match(x) is None,  # type: ignore
                    response,
                )

            csvreader = csv.DictReader(response, fieldnames=fieldnames, **self.dialect)

            if skip_first_line:
                next(csvreader)
            no_update = get_no_update_value(r, url) if is_demisto_version_ge("6.5.0") else True
            results.append({url: {"result": csvreader, "no_update": no_update}})

        return results

    def get_feed_content_divided_to_lines(self, url, raw_response):
        """Fetch feed data and divides its content to lines

        Args:
            url: Current feed's url.
            raw_response: The raw response from the feed's url.

        Returns:
            List. List of lines from the feed content.
        """
        if self.feed_url_to_config and self.feed_url_to_config.get(url).get("is_zipped_file"):  # type: ignore
            response_content = gzip.decompress(raw_response.content)
        else:
            response_content = raw_response.content

        return response_content.decode(self.encoding).split("\n")


def get_no_update_value(response: requests.models.Response, url: str) -> bool:
    """
    detect if the feed response has been modified according to the headers etag and last_modified.
    For more information, see this:
    https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified
    https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag
    Args:
        response: (requests.Response) The feed response.
        url: (str) The feed URL (service).
    Returns:
        boolean with the value for noUpdate argument.
        The value should be False if the response was modified.
    """
    if response.status_code == 304:
        demisto.debug("No new indicators fetched, createIndicators will be executed with noUpdate=True.")
        return True

    etag = response.headers.get("ETag")
    last_modified = response.headers.get("Last-Modified")
    current_time = datetime.utcnow()
    # Save the current time as the last updated time. This will be used to indicate the last time the feed was updated in XSOAR.
    last_updated = current_time.strftime(DATE_FORMAT)

    if not etag and not last_modified:
        demisto.debug("Last-Modified and Etag headers are not exists,createIndicators will be executed with noUpdate=False.")
        return False

    last_run = demisto.getLastRun()
    last_run[url] = {"last_modified": last_modified, "etag": etag, "last_updated": last_updated}
    demisto.setLastRun(last_run)

    demisto.debug(
        "New indicators fetched - the Last-Modified value has been updated,"
        " createIndicators will be executed with noUpdate=False."
    )
    return False


def determine_indicator_type(indicator_type, default_indicator_type, auto_detect, value):
    """
    Detect the indicator type of the given value.
    Args:
        indicator_type: (str) Indicator type given in the config.
        default_indicator_type: Indicator type which was inserted as a param of the integration by user.
        auto_detect: (bool) True whether auto detection of the indicator type is wanted.
        value: (str) The value which we'd like to get indicator type of.
    Returns:
        Str which stands for the indicator type after detection.
    """
    if auto_detect:
        indicator_type = auto_detect_indicator_type(value)
    if not indicator_type:
        indicator_type = default_indicator_type
    return indicator_type


def module_test_command(client: Client, args):
    client.build_iterator()
    return "ok", {}, {}


def date_format_parsing(date_string):
    """
    formats a datestring to the ISO-8601 format which the server expects to recieve
    :param date_string: Date represented as a tring
    :return: ISO-8601 date string
    """
    formatted_date = dateparser.parse(date_string, settings={"TIMEZONE": "UTC"})
    assert formatted_date is not None, f"failed parsing {date_string}"
    return formatted_date.strftime(DATE_FORMAT)


def create_fields_mapping(raw_json: dict[str, Any], mapping: dict[str, tuple | str]):
    fields_mapping = {}  # type: dict

    for key, field in mapping.items():
        regex_extractor = None
        formatter_string = None
        field_mapper_function = None

        # case 'value_from_feed', regex_string_extractor, string_formatter
        if isinstance(field, tuple) and len(field) == 3:
            field, regex_extractor, formatter_string = field

        # case 'value_from_feed', 'field_mapper_function'
        elif isinstance(field, tuple) and len(field) == 2:
            field, field_mapper_function = field

        if not raw_json.get(field):  # type: ignore
            continue

        if not regex_extractor:
            field_value = raw_json[field]  # type: ignore
        else:
            try:
                field_value = re.match(regex_extractor, raw_json[field]).group(1)  # type: ignore
            except Exception:
                field_value = raw_json[field]  # type: ignore

        field_value = formatter_string.format(field_value) if formatter_string else field_value
        field_value = field_mapper_function(field_value) if field_mapper_function else field_value
        fields_mapping[key] = field_value

        if key in ["firstseenbysource", "lastseenbysource"]:
            fields_mapping[key] = date_format_parsing(fields_mapping[key])

    return fields_mapping


def fetch_indicators_command(
    client: Client,
    default_indicator_type: str,
    auto_detect: bool | None,
    limit: int = 0,
    create_relationships: bool = False,
    enrichment_excluded: bool = False,
    **kwargs,
):
    iterator = client.build_iterator(**kwargs)
    relationships_of_indicator = []
    indicators = []
    config = client.feed_url_to_config or {}

    # set noUpdate flag in createIndicators command True only when all the results from all the urls are True.
    no_update = all(next(iter(item.values())).get("no_update", False) for item in iterator)

    for url_to_reader in iterator:
        for url, reader in url_to_reader.items():
            mapping = config.get(url, {}).get("mapping", {})
            for item in reader.get("result", []):
                raw_json = dict(item)
                fields_mapping = create_fields_mapping(raw_json, mapping) if mapping else {}
                value = item.get(client.value_field) or fields_mapping.get("Value")
                if not value and len(item) > 1:
                    value = next(iter(item.values()))
                if value:
                    raw_json["value"] = value
                    conf_indicator_type = config.get(url, {}).get("indicator_type")
                    indicator_type = determine_indicator_type(conf_indicator_type, default_indicator_type, auto_detect, value)
                    raw_json["type"] = indicator_type
                    # if relationships param is True and also the url returns relationships
                    if (
                        create_relationships
                        and config.get(url, {}).get("relationship_name")
                        and fields_mapping.get("relationship_entity_b")
                    ):
                        relationships_lst = EntityRelationship(
                            name=config.get(url, {}).get("relationship_name"),
                            entity_a=value,
                            entity_a_type=indicator_type,
                            entity_b=fields_mapping.get("relationship_entity_b"),
                            entity_b_type=FeedIndicatorType.indicator_type_by_server_version(
                                config.get(url, {}).get("relationship_entity_b_type")
                            ),
                        )
                        relationships_of_indicator = [relationships_lst.to_indicator()]

                    indicator = {
                        "value": value,
                        "type": indicator_type,
                        "rawJSON": raw_json,
                        "fields": fields_mapping,
                        "relationships": relationships_of_indicator,
                    }
                    indicator["fields"]["tags"] = client.tags

                    if client.tlp_color:
                        indicator["fields"]["trafficlightprotocol"] = client.tlp_color

                    if enrichment_excluded:
                        indicator["enrichmentExcluded"] = enrichment_excluded

                    indicators.append(indicator)
                    # exit the loop if we have more indicators than the limit
                    if limit and len(indicators) >= limit:
                        return indicators, no_update

    return indicators, no_update


def get_indicators_command(client, args: dict, tags: list[str] | None = None):
    if tags is None:
        tags = []
    itype = args.get("indicator_type", demisto.params().get("indicator_type"))
    try:
        limit = int(args.get("limit", 50))
    except ValueError:
        raise ValueError("The limit argument must be a number.")
    auto_detect = demisto.params().get("auto_detect_type")
    relationships = demisto.params().get("create_relationships", False)
    enrichment_excluded = demisto.params().get("enrichmentExcluded", False) or (
        demisto.params().get("tlp_color") == "RED" and is_xsiam_or_xsoar_saas()
    )
    indicators_list, _ = fetch_indicators_command(client, itype, auto_detect, limit, relationships, enrichment_excluded)
    entry_result = indicators_list[:limit]
    hr = tableToMarkdown("Indicators", entry_result, headers=["value", "type", "fields"])
    return hr, {}, indicators_list


def feed_main(feed_name, params=None, prefix=""):  # pragma: no cover
    if not params:
        params = {k: v for k, v in demisto.params().items() if v is not None}
    handle_proxy()
    client = Client(**params)
    command = demisto.command()
    if command != "fetch-indicators":
        demisto.info(f"Command being called is {command}")
    if prefix and not prefix.endswith("-"):
        prefix += "-"
    # Switch case
    commands: dict = {"test-module": module_test_command, f"{prefix}get-indicators": get_indicators_command}
    try:
        if command == "fetch-indicators":
            indicators, no_update = fetch_indicators_command(
                client,
                params.get("indicator_type"),
                params.get("auto_detect_type"),
                params.get("limit"),
                params.get("create_relationships"),
                params.get("enrichmentExcluded", False),
            )

            # check if the version is higher than 6.5.0 so we can use noUpdate parameter
            if is_demisto_version_ge("6.5.0"):
                if not indicators:
                    demisto.createIndicators(indicators, noUpdate=no_update)  # type: ignore
                else:
                    # we submit the indicators in batches
                    for b in batch(indicators, batch_size=2000):
                        demisto.createIndicators(b, noUpdate=no_update)  # type: ignore
            else:
                # call createIndicators without noUpdate arg
                if not indicators:
                    demisto.createIndicators(indicators)  # type: ignore
                else:
                    for b in batch(indicators, batch_size=2000):  # type: ignore
                        demisto.createIndicators(b)

        else:
            args = demisto.args()
            args["feed_name"] = feed_name
            readable_output, outputs, raw_response = commands[command](client, args)
            return_outputs(readable_output, outputs, raw_response)
    except Exception as e:
        err_msg = (
            f"Error in {feed_name} Integration - Encountered an issue with createIndicators"
            if "failed to create" in str(e)
            else f"Error in {feed_name} Integration [{e}]"
        )
        return_error(err_msg)

README

To use the common CSV Feed API logic, run the following:

def main():
    feed_main(<FEED_NAME>)


from CSVFeedApiModule import *  # noqa: E402

if __name__ in ["builtins", "__main__"]:
    main()

feed_main is the main execution of the Feed API module. It can be extended or overriden in the integration main function.
Note that the module expectes a feed_url_to_config parameter to extract the indicators. This is similar to the configuration in minemeld.
See the module class docstring for an example.