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.