JSONFeedApiModule

Common code that will be appended into each JSON Feed integration when it's deployed.

python · ApiModules

Source

# pylint: disable=E9010
from CommonServerPython import *

""" IMPORTS """
from collections.abc import Callable
from functools import cache

import jmespath
import urllib3

# disable insecure warnings
urllib3.disable_warnings()

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


class Client:
    def __init__(
        self,
        url: str = "",
        credentials: dict = None,
        feed_name_to_config: dict[str, dict] = None,
        source_name: str = "JSON",
        extractor: str = "",
        indicator: str = "indicator",
        insecure: bool = False,
        cert_file: str = None,
        key_file: str = None,
        headers: dict | str = None,  # type: ignore[assignment]
        tlp_color: str | None = None,
        data: str | dict = None,  # type: ignore[assignment]
        **_,
    ):
        """
        Implements class for miners of JSON feeds over http/https.
        :param url: URL of the feed.
        :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 extractor: JMESPath expression for extracting the indicators from
        :param indicator: the JSON attribute to use as indicator. Default: indicator
        :param source_name: feed source name
        If None no additional attributes will be extracted.
        :param insecure: if *False* feed HTTPS server certificate will be verified
        Hidden parameters:
        :param: cert_file: client certificate
        :param: key_file: private key of the client certificate
        :param: headers: Header parameters are optional to specify a user-agent or an api-token.
            Support also a multiline string where each line contains a header of the format 'Name: Value'
            Example: headers = {'user-agent': 'my-app/0.0.1'} or "Authorization: Bearer"
            (curl -H "Authorization: Bearer " "https://api-url.com/api/v1/iocs?first_seen_since=2016-1-1")
        :param tlp_color: Traffic Light Protocol color.
        :param data: Data to post. If not specified will do a GET request. May also be passed as dict as
            supported by requests. If passed as a string will set content-type to
            application/x-www-form-urlencoded if not specified in the headers.

         Example:
            Example feed config:
            'AMAZON': {
                'url': 'https://ip-ranges.amazonaws.com/ip-ranges.json',
                'extractor': "prefixes[?service=='AMAZON']",
                'indicator': 'ip_prefix',
            }
        """

        self.source_name = source_name or "JSON"
        if feed_name_to_config:
            self.feed_name_to_config = feed_name_to_config
        else:
            self.feed_name_to_config = {
                self.source_name: {
                    "url": url,
                    "indicator": indicator or "indicator",
                    "extractor": extractor or "@",
                }
            }

        # Request related attributes
        self.url = url
        self.verify = not insecure
        self.auth: tuple[str, str] | None = None
        self.headers = self.parse_headers(headers)

        if credentials:
            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", "")
                if username is not None and password is not None:
                    self.auth = (username, password)

        self.cert = (cert_file, key_file) if cert_file and key_file else None
        self.tlp_color = tlp_color
        self.post_data = data

        if isinstance(self.post_data, str):
            content_type_header = "Content-Type"
            if content_type_header.lower() not in [k.lower() for k in self.headers]:
                self.headers[content_type_header] = "application/x-www-form-urlencoded"

    @staticmethod
    def parse_headers(headers: dict | str | None) -> dict:
        """Parse headers if passed as a string. Support a multiline string where each line contains a header
        of the format 'Name: Value'

        Args:
            headers (Optional[Union[dict, str]]): either dict or string to parse

        Returns:
            dict: returns a headers dict or None
        """
        if not headers:
            return {}
        if isinstance(headers, str):
            res = {}
            for line in headers.splitlines():
                if line.strip():  # ignore empty lines
                    key_val = line.split(":", 1)
                    res[key_val[0].strip()] = key_val[1].strip()
            return res
        else:
            return headers

    def build_iterator(self, feed: dict, feed_name: str, **kwargs) -> tuple[list, bool]:
        url = feed.get("url", self.url)

        if is_demisto_version_ge("6.5.0"):
            prefix_feed_name = get_formatted_feed_name(feed_name)  # Support for AWS feed

            # Set the If-None-Match and If-Modified-Since headers
            # if we have etag or last_modified values in the context, with server version higher than 6.5.0.
            last_run = demisto.getLastRun()
            etag = last_run.get(prefix_feed_name, {}).get("etag") or last_run.get(feed_name, {}).get("etag")
            last_modified = last_run.get(prefix_feed_name, {}).get("last_modified") or last_run.get(feed_name, {}).get(
                "last_modified"
            )  # noqa: E501
            last_updated = last_run.get(prefix_feed_name, {}).get("last_updated") or last_run.get(feed_name, {}).get(
                "last_updated"
            )  # noqa: E501
            # 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
            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

        result: list[dict] = []
        if self.post_data:
            r = self.http_post(url, **kwargs)
        else:
            r = self.http_get(url, **kwargs)

        try:
            r.raise_for_status()
            if r.content:
                demisto.debug(f"JSON: found content for {feed_name}")
                data = r.json()
                result = jmespath.search(expression=feed.get("extractor"), data=data) or []
                if not result:
                    demisto.debug(f"No results found - retrieved data is: {data}")

        except ValueError as VE:
            raise ValueError(f"Could not parse returned data to Json. \n\nError massage: {VE}")
        if is_demisto_version_ge("6.5.0"):
            return result, get_no_update_value(r, feed_name)
        return result, True

    @cache
    def http_get(self, url, **kwargs) -> requests.Response:
        return requests.get(url=url, verify=self.verify, auth=self.auth, cert=self.cert, headers=self.headers, **kwargs)

    def http_post(self, url, **kwargs) -> requests.Response:
        return requests.post(
            url=url, data=self.post_data, verify=self.verify, auth=self.auth, cert=self.cert, headers=self.headers, **kwargs
        )


def get_no_update_value(response: requests.Response, feed_name: 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.
        feed_name: (str) the name of the feed.
    Returns:
        boolean with the value for noUpdate argument.
        The value should be False if the response was modified.
    """
    # HTTP status code 304 (Not Modified) set noUpdate to True.
    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[feed_name] = {"last_modified": last_modified, "etag": etag, "last_updated": last_updated}
    demisto.setLastRun(last_run)
    demisto.debug(f"JSON: The new last run is: {last_run}")
    demisto.debug(
        "New indicators fetched - the Last-Modified value has been updated,"
        " createIndicators will be executed with noUpdate=False."
    )
    return False


def get_formatted_feed_name(feed_name: str):
    """support for AWS Feed config name, that contains $$ in the name.
        example: AMAZON$$CIDR
    Args:
        feed_name (str): The feed config name
    """
    prefix_feed_name = ""
    if "$$" in feed_name:
        prefix_feed_name = feed_name.split("$$")[0]
        return prefix_feed_name

    return feed_name


def test_module(client: Client, limit) -> str:  # pragma: no cover
    for feed_name, feed in client.feed_name_to_config.items():
        custom_build_iterator = feed.get("custom_build_iterator")
        if custom_build_iterator:
            custom_build_iterator(client, feed, limit)
        else:
            client.build_iterator(feed, feed_name)
    return "ok"


def fetch_indicators_command(
    client: Client,
    indicator_type: str,
    feedTags: list,
    auto_detect: bool,
    create_relationships: bool = False,
    limit: int = 0,
    remove_ports: bool = False,
    enrichment_excluded: bool = False,
    **kwargs,
) -> tuple[list[dict], bool]:
    """
    Fetches the indicators from client.
    :param client: Client of a JSON Feed
    :param indicator_type: the default indicator type
    :param feedTags: the indicator tags
    :param auto_detect: a boolean indicates if we should automatically detect the indicator_type
    :param limit: given only when get-indicators command is running. function will return number indicators as the limit
    :param create_relationships: whether to add connected indicators
    """
    indicators: list[dict] = []
    feeds_results = {}
    no_update = False
    for feed_name, feed in client.feed_name_to_config.items():
        custom_build_iterator = feed.get("custom_build_iterator")
        if custom_build_iterator:
            indicators_from_feed = custom_build_iterator(client, feed, limit, **kwargs)
            if not isinstance(indicators_from_feed, list):
                raise Exception("Custom function to handle with pagination must return a list type")
            feeds_results[feed_name] = indicators_from_feed
        else:
            feeds_results[feed_name], no_update = client.build_iterator(feed, feed_name, **kwargs)

    indicators_values: Set[str] = set()
    indicators_values_indexes = {}

    for service_name, items in feeds_results.items():
        feed_config = client.feed_name_to_config.get(service_name, {})
        indicator_field = str(feed_config.get("indicator") if feed_config.get("indicator") else "indicator")
        indicator_type = str(feed_config.get("indicator_type", indicator_type))
        use_prefix_flat = bool(feed_config.get("flat_json_with_prefix", False))
        mapping_function = feed_config.get("mapping_function", indicator_mapping)
        handle_indicator_function = feed_config.get("handle_indicator_function", handle_indicator)
        create_relationships_function = feed_config.get("create_relations_function")
        service_name = get_formatted_feed_name(service_name)

        for item in items:
            if isinstance(item, str):
                item = {indicator_field: item}

            indicator_value = item.get(indicator_field)
            if indicator_value is None:
                continue
            if indicator_value not in indicators_values:
                indicators_values_indexes[indicator_value] = len(indicators_values)
                indicators_values.add(indicator_value)
            else:
                service = indicators[indicators_values_indexes[indicator_value]].get("rawJSON", {}).get("service", "")
                if service and service_name not in service.split(","):
                    service_name += f", {service}"
                indicators[indicators_values_indexes[indicator_value]]["rawJSON"]["service"] = service_name
                continue

            indicators.extend(
                handle_indicator_function(
                    client,
                    item,
                    feed_config,
                    service_name,
                    indicator_type,
                    indicator_field,
                    use_prefix_flat,
                    feedTags,
                    auto_detect,
                    mapping_function,
                    create_relationships,
                    create_relationships_function,
                    remove_ports,
                    enrichment_excluded=enrichment_excluded,
                )
            )

            if limit and len(indicators) >= limit:  # We have a limitation only when get-indicators command is
                # called, and then we return for each service_name "limit" of indicators
                break
    return indicators, no_update


def indicator_mapping(mapping: dict, indicator: dict, attributes: dict):
    for map_key in mapping:
        if map_key in attributes:
            fields = mapping[map_key].split(".")
            if len(fields) > 1:
                if indicator["fields"].get(fields[0]):
                    indicator["fields"][fields[0]][0].update({fields[1]: attributes.get(map_key)})
                else:
                    indicator["fields"][fields[0]] = [{fields[1]: attributes.get(map_key)}]
            else:
                indicator["fields"][mapping[map_key]] = attributes.get(map_key)  # type: ignore


def handle_indicator(
    client: Client,
    item: dict,
    feed_config: dict,
    service_name: str,
    indicator_type: str,
    indicator_field: str,
    use_prefix_flat: bool,
    feedTags: list,
    auto_detect: bool,
    mapping_function: Callable = indicator_mapping,
    create_relationships: bool = False,
    relationships_func: Callable | None = None,
    remove_ports: bool = False,
    enrichment_excluded: bool = False,
) -> list[dict]:
    indicator_list = []
    mapping = feed_config.get("mapping")
    take_value_from_flatten = False
    indicator_value = item.get(indicator_field)
    if not indicator_value:
        take_value_from_flatten = True
    current_indicator_type = determine_indicator_type(indicator_type, auto_detect, indicator_value)

    if not current_indicator_type:
        demisto.debug(
            f"Could not determine indicator type for value: {indicator_value} from field: {indicator_field}."
            f" Skipping item: {item}"
        )
        return []

    indicator = {
        "type": current_indicator_type,
        "fields": {
            "tags": feedTags,
        },
    }

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

    attributes = {"source_name": service_name, "type": current_indicator_type}
    attributes.update(extract_all_fields_from_indicator(item, indicator_field, flat_with_prefix=use_prefix_flat))

    if take_value_from_flatten:
        indicator_value = attributes.get(indicator_field)
    indicator["value"] = indicator_value
    attributes["value"] = indicator_value
    if mapping:
        mapping_function(mapping, indicator, attributes)

    if create_relationships and relationships_func and feed_config.get("relation_name"):
        indicator["relationships"] = relationships_func(feed_config, mapping, attributes)

    if feed_config.get("rawjson_include_indicator_type"):
        item["_indicator_type"] = current_indicator_type

    if remove_ports and indicator["type"] == "IP" and indicator["value"]:
        indicator["value"] = indicator["value"].split(":")[0]

    indicator["rawJSON"] = item

    if enrichment_excluded:
        indicator["enrichmentExcluded"] = enrichment_excluded

    indicator_list.append(indicator)

    return indicator_list


def determine_indicator_type(indicator_type, auto_detect, value):
    """
    Detect the indicator type of the given value.
    Args:
        indicator_type: (str) Given indicator type.
        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)
    return indicator_type


def extract_all_fields_from_indicator(indicator: dict, indicator_key: str, flat_with_prefix: bool = False) -> dict:
    """Flattens the JSON object to create one dictionary of values
    Args:
        indicator(dict): JSON object that holds indicator full data.
        indicator_key(str): The key that holds the indicator value.
        flat_with_prefix(bool): Indicates whether should add the inner json path as part of the keys in the flatten json
    Returns:
        dict. A dictionary of the fields in the JSON object.
    """
    fields = {}  # type: dict

    def insert_value_to_fields(key, value):
        if key in fields:
            if not isinstance(fields[key], list):
                fields[key] = [fields[key]]
            fields[key].append(value)
        else:
            fields[key] = value

    def extract(json_element, prefix_field="", use_prefix=False):
        if isinstance(json_element, dict):
            for key, value in json_element.items():
                if value and isinstance(value, dict):
                    if use_prefix:
                        extract(value, prefix_field=f"{prefix_field}_{key}" if prefix_field else key, use_prefix=use_prefix)
                    else:
                        extract(value)
                elif key != indicator_key:
                    if use_prefix:
                        insert_value_to_fields(f"{prefix_field}_{key}" if prefix_field else key, value)
                    else:
                        insert_value_to_fields(key, value)
        elif json_element and indicator_key not in json_element:
            for key, value in json_element:
                insert_value_to_fields(key, value)

    extract(indicator, use_prefix=flat_with_prefix)

    return fields


def feed_main(params, feed_name, prefix):  # pragma: no cover
    handle_proxy()
    client = Client(**params)
    indicator_type = params.get("indicator_type")
    auto_detect = params.get("auto_detect_type")
    feedTags = argToList(params.get("feedTags"))
    limit = int(demisto.args().get("limit", 10))
    enrichment_excluded = params.get("enrichmentExcluded", False) or (
        params.get("tlp_color") == "RED" and is_xsiam_or_xsoar_saas()
    )
    command = demisto.command()
    if prefix and not prefix.endswith("-"):
        prefix += "-"
    if command != "fetch-indicators":
        demisto.info(f"Command being called is {demisto.command()}")
    try:
        if command == "test-module":
            return_results(test_module(client, limit))

        elif command == "fetch-indicators":
            remove_ports = argToBoolean(params.get("remove_ports", False))
            create_relationships = params.get("create_relationships")
            indicators, no_update = fetch_indicators_command(
                client,
                indicator_type,
                feedTags,
                auto_detect,
                create_relationships,
                remove_ports=remove_ports,
                enrichment_excluded=enrichment_excluded,
            )
            demisto.debug(f"Received {len(indicators)} indicators, no_update={no_update}")

            # 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)
                else:
                    for b in batch(indicators, batch_size=2000):
                        demisto.createIndicators(b, noUpdate=no_update)

            else:
                # call createIndicators without noUpdate arg
                if not indicators:
                    demisto.createIndicators(indicators)
                else:
                    for b in batch(indicators, batch_size=2000):
                        demisto.createIndicators(b)

        elif command == f"{prefix}get-indicators":
            remove_ports = argToBoolean(demisto.args().get("remove_ports", False))
            create_relationships = params.get("create_relationships")
            indicators, _ = fetch_indicators_command(
                client, indicator_type, feedTags, auto_detect, create_relationships, limit, remove_ports
            )

            hr = tableToMarkdown("Indicators", indicators, headers=["value", "type", "rawJSON"])

            return_results(CommandResults(readable_output=hr, raw_response=indicators))

    except Exception as err:
        err_msg = f"Error in {feed_name} integration [{err}]"
        return_error(err_msg)

README

To use the common JSON feed API logic, run the following command to import the JSONFeedApiModule.
The module expects the feed to be configured with the following parameters:

Per sub-feed name:

  • URL
  • jmespath extractor
  • indicator name
  • indicator type

See the below example:

def main():
    ...
    
    feed_name_to_config = {
        'AMAZON': {
            'url': 'https://ip-ranges.amazonaws.com/ip-ranges.json',
            'extractor': "prefixes[?service=='AMAZON']",
            'indicator': 'ip_prefix',
            'indicator_type': FeedIndicatorType.IP,
        }
    }

    params = {k: v for k, v in demisto.params().items() if v is not None}
    params['feed_name_to_config'] = feed_name_to_config
    feed_main(params, 'AWS Feed')


from JSONFeedApiModule import *  # noqa: E402

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