XQLDSHelper
python · XQLDSHelper
Source
import demistomock as demisto # noqa: F401 from CommonServerPython import * # noqa: F401 import re import enum import pytz import gzip import math import base64 import hashlib import dateparser import itertools import colorsys import traceback import urllib.parse from collections import defaultdict from collections.abc import Iterable, Hashable DEFAULT_POLLING_INTERVAL = 10 # in seconds DEFAULT_RETRY_INTERVAL = 10 # in seconds DEFAULT_RETRY_MAX = 10 DEFAULT_QUERY_TIMEOUT_DURATION = 60 # in seconds def to_float(val: Any) -> float | int: """Ensure the value is of type number (float or int). :param val: The value. :return: A float or int converted from `val`. """ if val is None: return 0 try: val = float(val) return int(val) if val.is_integer() else val except (ValueError, TypeError): return 0 def get_target_type(): if is_platform(): return "issues" elif is_xsiam(): return "alerts" else: return "incidents" def to_str(val: Any) -> str: """Ensure the value is of type string. :param val: The value. :return: A str converted from `val`. """ return val if isinstance(val, str) else json.dumps(val) class CacheType(enum.StrEnum): NONE = "none" RECORDSET = "recordset" ENTRY = "entry" class DefaultEntryScope(enum.StrEnum): NO_RECORDSET = "no_recordset" QUERY_SKIPPED = "query_skipped" class ContextData: def __init__( self, context: dict[str, Any] | None = None, incident: dict[str, Any] | None = None, alert: dict[str, Any] | None = None, issue: dict[str, Any] | None = None, value: dict[str, Any] | None = None, ) -> None: self.__context: dict[str, Any] = context or {} self.__value: dict[str, Any] = value or {} self.__specials: dict[str, Any] = { "issue": issue or {}, "alert": alert or {}, "incident": incident or {}, "lists": None, } def get(self, key: str | None = None) -> Any: """Get the context value :param key: The dt expressions (string within ${}). :return: The value. """ if not key: return None if key != "." and not key.startswith(".=") and key.startswith("."): dx = self.__value key = key[1:] else: for prefix in self.__specials: k = key[len(prefix) :] if key.startswith(prefix) and k[:1] in ("", ".", "(", "="): if prefix == "lists": if list_name := re.split("[.(=]", k[1:], maxsplit=1)[0]: dx = {prefix: {list_name: execute_command("getList", {"listName": list_name})}} break else: dx = self.__specials break else: dx = self.__context return demisto.dt(dx, key) def inherit( self, value: dict[str, Any] | None = None, ) -> "ContextData": """Create a ContextData with the new value :param value: The new value. :return: ContextData created. """ return ContextData( context=self.__context, incident=self.__specials.get("incident"), alert=self.__specials.get("alert"), issue=self.__specials.get("issue"), value=value, ) class Formatter: def __init__(self, variable_substitution: tuple[str, str], keep_symbol_to_null: bool) -> None: self.__keep_symbol_to_null = keep_symbol_to_null self.__var_opening, self.__var_closing = variable_substitution if not self.__var_opening: raise DemistoException("opening marker is required.") @staticmethod def __is_closure(source: str, ci: int, closure_marker: str) -> bool: if closure_marker: return source[ci : ci + len(closure_marker)] == closure_marker else: c = source[ci] if c.isspace(): return True elif c.isascii(): return c != "_" and not c.isalnum() else: return False @staticmethod def __extract_dt( dtstr: str, dx: ContextData | None, ) -> Any: """Extract dt expression :param dtstr: The dt expressions (string within ${}). :param dx: The context instance. :return: The value extracted. """ try: return dx.get(dtstr) if dx else dtstr except Exception: return None def __extract( self, source: str, dx: ContextData | None, si: int, markers: tuple[str, str] | None, ) -> tuple[Any, int | None]: """Extract a template text, or an enclosed value within starting and ending marks :param source: The template text, or the enclosed value starts with the next charactor of a start marker :param dx: The context data :param si: The index of `source` to start extracting :param markers: The opening and closing markers to find an end position for parsing an enclosed value. It must be None when the template text is given to `source`. :return: The extracted value and index of `source` when parsing ended. The index is the next after the end marker when extracting the enclosed value. """ out = None ci = si while ci < len(source): if markers is not None and Formatter.__is_closure(source, ci, markers[1]): key = source[si:ci] if out is None else str(out) + source[si:ci] if markers == (self.__var_opening, self.__var_closing): xval = self.__extract_dt(key, dx) if xval is None and self.__keep_symbol_to_null: xval = markers[0] + key + markers[1] else: xval = self.build(xval, dx) else: xval = markers[0] + key + markers[1] return xval, ci + len(markers[1]) elif source[ci : ci + len(self.__var_opening)] == self.__var_opening: xval, ei = self.__extract( source=source, dx=dx, si=ci + len(self.__var_opening), markers=(self.__var_opening, self.__var_closing), ) if si != ci: out = source[si:ci] if out is None else str(out) + source[si:ci] if ei is None: xval = self.__var_opening ei = ci + len(self.__var_opening) if out is None: out = xval elif xval is not None: out = str(out) + str(xval) si = ci = ei elif markers is None: ci += 1 elif endc := {"(": ")", "{": "}", "[": "]", '"': '"', "'": "'", "`": "`"}.get(source[ci]): xval, ei = self.__extract( source=source, dx=dx, si=ci + 1, markers=(source[ci], endc), ) if si != ci: out = source[si:ci] if out is None else str(out) + source[si:ci] if ei is None: xval = source[ci] ei = ci + len(source[ci]) if out is None: out = xval elif xval is not None: out = str(out) + str(xval) si = ci = ei elif source[ci] == "\\": ci += 2 else: ci += 1 if markers is not None: # unbalanced braces, brackets, quotes, etc. return None, None elif si >= len(source): return out, ci elif out is None: return source[si:], ci else: return str(out) + source[si:], ci def build(self, template: Any, context: ContextData | None) -> Any: """Format a text from a template including DT expressions :param template: The template. :param context: The context instance. :return: The text built from the template. """ if isinstance(template, dict): return {self.build(k, context): self.build(v, context) for k, v in template.items()} elif isinstance(template, list): return [self.build(v, context) for v in template] elif isinstance(template, str): return self.__extract(source=template, dx=context, si=0, markers=None)[0] if template else "" else: return template class SortableValue: """ The custom value object class, which enables you to sort for any types of data even in different types. """ def __init__( self, value: Any, ) -> None: self.__value = value def __lt__(self, other: Any) -> bool: def __lt(obj1: Any, obj2: Any) -> bool: if any( f(obj1) and f(obj2) for f in [ lambda x: isinstance(x, int | float), lambda x: isinstance(x, bool), lambda x: isinstance(x, str), ] ): return obj1 < obj2 # type: ignore[operator] elif obj1 is None or obj2 is None: return (obj2 is None) < (obj1 is None) else: def __get_order( v: Any, ) -> int: if isinstance(v, bool): return 1 elif isinstance(v, int | float): return 2 elif isinstance(v, str): return 3 else: return 4 order1 = __get_order(obj1) order2 = __get_order(obj2) if n := (order1 > order2) - (order1 < order2): return n < 0 else: return json.dumps(obj1) < json.dumps(obj2) if not isinstance(other, SortableValue): return False return __lt(self.__value, other.__value) class QueryParams: def __init__( self, query_name: str, query_string: str, earliest_time: datetime, latest_time: datetime, ) -> None: if not query_string: raise DemistoException("Query string is required.") if earliest_time.tzinfo is None or latest_time.tzinfo is None: raise DemistoException("earliest_time and latest_time must be timezone aware.") if earliest_time > latest_time: raise DemistoException(f"latest_time ({latest_time}) must be equal to or later than earliest_time ({earliest_time}).") self.__query_name = query_name self.__query_string = query_string self.__query_string_normalized = "\n".join(x.strip() for x in query_string.splitlines()).strip() self.__earliest_time = earliest_time self.__latest_time = latest_time @property def query_name( self, ) -> str: return self.__query_name @property def normalized_query_string( self, ) -> str: return self.__query_string_normalized @property def query_string( self, ) -> str: return self.__query_string @property def earliest_time( self, ) -> datetime: return self.__earliest_time @property def latest_time( self, ) -> datetime: return self.__latest_time def get_earliest_time_iso( self, utc: bool = False, ) -> str: if utc: t = self.__earliest_time.astimezone(pytz.UTC) return t.isoformat(timespec="milliseconds").replace("+00:00", "Z") else: return self.__earliest_time.isoformat(timespec="milliseconds") def get_latest_time_iso( self, utc: bool = False, ) -> str: if utc: t = self.__latest_time.astimezone(pytz.UTC) return t.isoformat(timespec="milliseconds").replace("+00:00", "Z") else: return self.__latest_time.isoformat(timespec="milliseconds") def query_hash( self, ) -> str: # The input value doesn't include `query_name` for the hash. return hashlib.sha256( json.dumps( { "query_string": self.normalized_query_string, "earliest_time": self.get_earliest_time_iso(), "latest_time": self.get_latest_time_iso(), } ).encode() ).hexdigest() class Cache: CACHE_ROOT_KEY = "XQLDSHelperCache" @staticmethod def __compress(val: str) -> dict[Hashable, str]: return {"type": "gz+b85x", "data": base64.b85encode(gzip.compress(val.encode())).decode().replace("$", ":")} @staticmethod def build_query_params( query_params: QueryParams, ) -> dict[Hashable, Any]: return { "query_name": query_params.query_name, "query_string": Cache.__compress(query_params.query_string), "earliest_time": query_params.get_earliest_time_iso(), "latest_time": query_params.get_latest_time_iso(), } def __load_data( self, query_hash: str, cache_node: str, ) -> Any: cache = self.__repo.get(self.__key) if not isinstance(cache, dict): return None if cache.get("QueryHash") != query_hash: return None _data = demisto.get(cache, f"{cache_node}.data") _type = demisto.get(cache, f"{cache_node}.type") try: if _type == "gz+b85": return json.loads(gzip.decompress(base64.b85decode(_data.encode())).decode()) elif _type == "gz+b85x": return json.loads(gzip.decompress(base64.b85decode(_data.replace(":", "$").encode())).decode()) else: return None except Exception as e: demisto.debug(f"Failed to load cache data [{cache_node}] - {e}") return None def __save_data( self, query_params: QueryParams, cache_node: str, data: Any, ) -> None: cache = { "QueryParams": self.build_query_params(query_params), "QueryHash": query_params.query_hash(), cache_node: self.__compress(json.dumps(data)), } if incident_id := demisto.incident().get("id"): target = get_target_type() demisto.executeCommand( "executeCommandAt", { "command": "Set", target: incident_id, "arguments": { "key": f"{Cache.CACHE_ROOT_KEY}.{self.__key}", "value": cache, "append": "false", }, }, ) def __init__( self, name: str, repo: dict[Hashable, Any] | None, ) -> None: name = urllib.parse.quote(name).replace(".", "%2E") self.__key = name self.__repo = repo or {} def save_recordset( self, query_params: QueryParams, execution_id: str, recordset: list[dict[Hashable, Any]], ) -> None: self.__save_data( query_params=query_params, cache_node="CacheDataset", data={ "execution_id": execution_id, "recordset": recordset, }, ) def save_entry( self, query_params: QueryParams, entry: dict[Hashable, Any], ) -> None: self.__save_data( query_params=query_params, cache_node="CacheEntry", data=entry, ) def load_recordset( self, query_hash: str, ) -> tuple[str, list[dict[Hashable, Any]]] | None: data = self.__load_data( query_hash=query_hash, cache_node="CacheDataset", ) if isinstance(data, list): return "", data elif isinstance(data, dict): execution_id = data.get("execution_id") or "" recordset = data.get("recordset") or [] return execution_id, recordset else: return None def load_entry( self, query_hash: str, ) -> dict[Hashable, Any] | None: entry = self.__load_data( query_hash=query_hash, cache_node="CacheEntry", ) return entry if isinstance(entry, dict) else None class CoreLock: """ This class provides locking to prevent the concurrent execution of multiple tasks. """ def __init__( self, module: str, name: str | None, info: str | None, timeout: int | None, using: str | None, ) -> None: """Initializes a lock instance. :param module: The name for the locking functions must be either core-lock or demisto-lock. :param name: The name to identify the lock. :param info: Additional information to provide for the lock. :param timeout: Timeout (seconds) for wait on lock to be freed. :param using: The name of an lock integration instance. """ if module == "core-lock": self.__func_lock = "core-lock-get" self.__func_unlock = "core-lock-release" elif module == "demisto-lock": self.__func_lock = "demisto-lock-get" self.__func_unlock = "demisto-lock-release" else: raise DemistoException(f"locking module must be either core-lock or demisto-lock - {module}") self.__name = name self.__info = info self.__timeout = timeout self.__using = using def lock( self, ) -> None: """Acquire a specific lock via *-lock-get.""" args = assign_params( name=self.__name, info=self.__info, timeout=self.__timeout, using=self.__using, ) args_str = ", ".join(f"{k}={v}" for k, v in args.items()) or "default" demisto.debug(f"Attempting to acquire lock: {self.__func_lock} - {args_str}") execute_command(self.__func_lock, args, extract_contents=False) demisto.debug(f"Lock successfully acquired: {self.__func_lock} - {args_str}") def unlock( self, ) -> None: """Release a specific lock via core-lock-release.""" args = assign_params( name=self.__name, using=self.__using, ) args_str = ", ".join(f"{k}={v}" for k, v in args.items()) or "default" execute_command(self.__func_unlock, args, extract_contents=False) demisto.debug(f"Lock released: {self.__func_unlock} - {args_str}") class XQLQuery: """ This class executes XQL queries. """ @staticmethod def __peek_response( res: list[dict[str, Any]], ) -> dict[Hashable, Any] | None: for ent in res: ec = ent.get("EntryContext") or {} for k, v in ec.items(): k, _, _ = k.partition("(") if k == "PaloAltoNetworksXQL.GenericQuery" and isinstance(v, dict): return v return None @staticmethod def __get_response( res: list[dict[str, Any]], ) -> dict[Hashable, Any]: resp = XQLQuery.__peek_response(res) if resp is None: raise DemistoException(f"Unable to get query results - {res}") else: return resp @staticmethod def __get_error_message( res: list[dict[str, Any]], ) -> str | None: if XQLQuery.__peek_response(res) is not None: return None elif is_error(res): if message := get_error(res): return message else: for ent in res: if message := ent.get("HumanReadable"): return message return f"Unable to get query results - {res}" def __init__( self, xql_query_instance: str | None = None, polling_interval: int = DEFAULT_POLLING_INTERVAL, retry_interval: int = DEFAULT_RETRY_INTERVAL, retry_max: int = DEFAULT_RETRY_MAX, query_timeout_duration: int = DEFAULT_QUERY_TIMEOUT_DURATION, core_lock: CoreLock | None = None, ) -> None: self.__xql_query_instance = xql_query_instance self.__polling_interval = polling_interval self.__retry_interval = retry_interval self.__retry_max = max(retry_max, 0) self.__query_timeout_duration = max(query_timeout_duration, 0) self.__core_lock = core_lock def query( self, query_params: QueryParams, ) -> tuple[str, list[dict[Hashable, Any]]]: """Execute an XQL query and get results :param query_params: The query parameters. :return: The execution ID ans list of fields retrieved. """ if self.__core_lock: self.__core_lock.lock() try: # Start the query time_frame = f"between {query_params.get_earliest_time_iso()} and {query_params.get_latest_time_iso()}" demisto.debug(f"Run XQL: {query_params.query_name} {time_frame}: {query_params.normalized_query_string}") for retry_count in range(self.__retry_max + 1): res = demisto.executeCommand( "xdr-xql-generic-query", assign_params( query_name=query_params.query_name, query=query_params.normalized_query_string, time_frame=time_frame, parse_result_file_to_context="true", using=self.__xql_query_instance, ), ) error_message = self.__get_error_message(res) if error_message is None: break if retry_count >= self.__retry_max or all( x not in error_message for x in [ "reached max allowed amount of parallel running queries", "maximum allowed number of parallel running queries has been reached", ] ): raise DemistoException(f"Failed to execute xdr-xql-generic-query. Error details:\n{error_message}") time.sleep(self.__retry_interval) # Poll and retrieve the record set response = self.__get_response(res) execution_id = response.get("execution_id") if not execution_id: raise DemistoException("No execution_id in the response.") timeout_time = None while True: status = response.get("status", "") if status == "SUCCESS": return execution_id, (response.get("results") or []) elif status == "PENDING": current_time = time.time() if timeout_time is None: timeout_time = current_time + self.__query_timeout_duration remaining_time = timeout_time - current_time if remaining_time <= 0: raise DemistoException(f"Unable to get query results within {self.__query_timeout_duration} seconds.") time.sleep(min(remaining_time, self.__polling_interval)) res = demisto.executeCommand( "xdr-xql-get-query-results", assign_params( query_id=execution_id, parse_result_file_to_context="true", using=self.__xql_query_instance, ), ) if is_error(res): error_message = get_error(res) raise DemistoException(f"Failed to execute xdr-xql-get-query-results. Error details:\n{error_message}") response = self.__get_response(res) else: raise DemistoException(f"Failed to get query results - {response}") finally: if self.__core_lock: self.__core_lock.unlock() class Query: """ This class allows you to get the query results. """ def __init__( self, query_params: QueryParams, xql_query: XQLQuery | None, cache: Cache | None, ) -> None: self.__query_params = query_params self.__xql_query = xql_query self.__cache = cache @property def query_params( self, ) -> QueryParams: return self.__query_params def available( self, ) -> bool: return self.__xql_query is not None def query( self, ) -> tuple[str, list[dict[Hashable, Any]]]: """Get the record set by running the query. It will return an empty list if the query is not available, :return: The quest execution ID and list of fields retrieved by the query. """ if self.__cache and (ret := self.__cache.load_recordset(self.__query_params.query_hash())): return ret elif self.__xql_query: execution_id, recordset = self.__xql_query.query(self.__query_params) if self.__cache: self.__cache.save_recordset(self.__query_params, execution_id, recordset) return execution_id, recordset else: return "", [] class EntryBuilder: """ This class helps to query XQL and build an entry data. """ @staticmethod def __enum_fields_by_group( recordset: Iterable[dict[Hashable, Any]], group_by: str, sort_by: str | None, asc: bool, ) -> Iterable[tuple[Hashable, Iterable[dict[Hashable, Any]]]]: """Enumerate fields with a group value by group :param recordset: The list of fields. :param group_by: The name of the field to make groups. :param sort_by: The field name by which to sort records within each group. :param asc: Set to True to sort the recordset in ascent order, Set to False for descent order. :return: Each group value with fields. """ groups = itertools.groupby( sorted( recordset, key=lambda v: SortableValue(v.get(group_by)), reverse=not asc, ), key=lambda v: v.get(group_by), ) if not sort_by: return groups else: return sorted( ( ( k, sorted( records, key=lambda v: SortableValue(v.get(sort_by)), reverse=not asc, ), ) for k, records in groups ), key=lambda v: SortableValue(next(iter(v[1]), {}).get(sort_by)), # type: ignore reverse=not asc, ) @staticmethod def __sum_by( recordset: list[dict[Hashable, Any]], sum_field: str, group_by: str, order_asc: bool, ) -> dict[Hashable, float]: """Sum field values by a field :param recordset: The list of fields. :param sum_field: The field name of the value to be summed. :param group_by: The field name to group the fields. :param order_asc: Set to True for ascending order, and False for descending order. :return: Mapping of field name with the sum value in order by the sum. """ d: dict[Hashable, float] = defaultdict(float) for fields in recordset: d[fields.get(group_by)] += to_float(fields.get(sum_field)) return dict(sorted(d.items(), key=lambda x: x[1], reverse=not order_asc)) @staticmethod def __make_color_palette( names: list[str], colors: dict[Hashable, str] | list[str] | str, ) -> dict[Hashable, str]: """Build a color table :param names: The list of names to be mapped to colors :param colors: The base color mapping, or colors for 'names' in order :return: The color mapping. (name and color) """ color_order: list[str] = [] if isinstance(colors, str): color_order = [colors] * len(names) elif isinstance(colors, list): color_order = colors color_map: dict[Hashable, str] = dict(zip(names, color_order + EntryBuilder.list_colors(len(names)))) if isinstance(colors, dict): color_map.update(colors) return color_map @staticmethod def __build_singley_chart( chart_type: str, params: dict[Hashable, Any], recordset: list[dict[Hashable, Any]], ) -> dict[Hashable, Any]: """Build a single-Y chart entry :param chart_type: The chart type. (bar or pie) :param params: The template parameters for a single-Y chart. :param recordset: The list of fields used to create the single-Y chart. :return: A single-Y chart entry. """ class Template: class Records: class Sort: def __init__( self, sort: dict[Hashable, Any], default_by: str, ) -> None: by = sort.get("by") or default_by assert isinstance(by, str) or by is None, f"sort.by must be of type str or null - {type(by)}" self.__by = by or default_by self.__asc = EntryBuilder.to_sort_order(sort.get("order") or "asc") @property def by( self, ) -> str: return self.__by @property def asc( self, ) -> bool: return self.__asc def __init__( self, records: dict[Hashable, Any], ) -> None: name_field = records.get("name-field") assert isinstance(name_field, str), f"name-field must be of type str - {type(name_field)}" self.__name_field = name_field data_field = records.get("data-field") assert isinstance(data_field, str), f"data-field must be of type str - {type(data_field)}" self.__data_field = data_field colors = records.get("colors") if isinstance(colors, list): for color in colors: assert isinstance(color, str), f"color must be of type str - {type(color)}" elif isinstance(colors, dict): for color in colors.values(): assert isinstance(color, str), f"color must be of type str - {type(color)}" elif colors is None: colors = [] elif not isinstance(colors, str): raise DemistoException(f"colors must be of type dict, list or null - {type(colors)}") self.__colors = colors sort = records.get("sort") or {} assert isinstance(sort, dict), f"sort must be of type dict or null - {type(sort)}" self.__sort = self.Sort(sort, default_by=self.data_field) @property def name_field( self, ) -> str: return self.__name_field @property def data_field( self, ) -> str: return self.__data_field @property def colors( self, ) -> dict[Hashable, str] | list[str] | str: return self.__colors @property def sort( self, ) -> Sort: return self.__sort class Field: def __init__( self, field: dict[Hashable, Any], default_color: str, ) -> None: assert isinstance(field, dict), f"field in .fields must be of type dict - {type(field)}" color = field.get("color") assert isinstance(color, str) or color is None, f"field.color must be of type str or null - {type(color)}" self.__color = color or default_color self.__label = field.get("label") assert ( isinstance(self.__label, str) or self.__label is None ), f"field.label must be of type str or null - {type(self.__label)}" @property def color( self, ) -> str: return self.__color @property def label( self, ) -> str | None: return self.__label def __init__( self, template: dict[Hashable, Any], ) -> None: self.__records: Records | None = None # pylint: disable=undefined-variable self.__fields: dict[Hashable, Field] | None = None # pylint: disable=undefined-variable group = template.get("group") if group == "records": records = template.get(group) assert isinstance(records, dict), f"records is required and must be of type dict - {type(records)}" self.__records = self.Records(records) elif group == "fields": fields = template.get(group) assert isinstance(fields, dict), f"fields is required and must be of type dict - {type(fields)}" self.__fields = { name: self.Field(field, default_color=color) for (name, field), color in zip(fields.items(), EntryBuilder.list_colors(len(fields))) } else: raise DemistoException(f"group must be 'records' or 'fields' - {group}") @property def records( self, ) -> Records | None: return self.__records @property def fields( self, ) -> dict[Hashable, Field] | None: return self.__fields template = Template(params) if records := template.records: sort: Template.Records.Sort = records.sort names = EntryBuilder.__sum_by( recordset=recordset, sum_field=records.data_field, group_by=records.name_field, order_asc=sort.asc, ) # Create color mapping colors = EntryBuilder.__make_color_palette( names=[x for x in names if isinstance(x, str)], colors=records.colors, ) # Build stats stats = [ assign_params( name=to_str(name), data=[to_float(value)], color=colors.get(name), ) for fields in sorted( recordset, key=lambda v: to_float(v.get(sort.by)), reverse=not sort.asc, ) for name, value in [(fields.get(records.name_field, ""), fields.get(records.data_field))] ] elif fields := template.fields: # Build stats stats = [ { "name": to_str(group.label or field or ""), "data": [sum(to_float(x.get(field)) for x in recordset)], "color": group.color, } for field, group in fields.items() ] else: stats = [] return { "Type": EntryType.WIDGET, "ContentsFormat": chart_type, "Contents": dict( assign_params(params=params.get("params")), stats=stats, ), } @staticmethod def __build_multiy_chart( chart_type: str, params: dict[Hashable, Any], recordset: list[dict[Hashable, Any]], ) -> dict[Hashable, Any]: """Build a multi-Y chart entry :param chart_type: The chart type. (bar or line) :param params: The template parameters for a multi-Y chart. :param recordset: The list of fields used to create the multi-Y chart. :return: A multi-Y chart entry. """ class Template: class X: def __init__( self, x: dict[Hashable, Any], ) -> None: by = x.get("by") assert isinstance(by, str), f"x.by must be of type str - {type(by)}" self.__by = by sort_by = x.get("sort-by") assert isinstance(sort_by, str) or sort_by is None, f"x.sort-by must be of type str or null - {type(sort_by)}" self.__sort_by = sort_by self.__asc = EntryBuilder.to_sort_order(x.get("order") or "asc") self.__field = x.get("field") assert ( isinstance(self.__field, str) or self.__field is None ), f"x.field must be of type str or null - {type(self.__field)}" @property def by( self, ) -> str: return self.__by @property def sort_by( self, ) -> str | None: return self.__sort_by @property def asc( self, ) -> bool: return self.__asc @property def field( self, ) -> str | None: return self.__field class Y: class Records: def __init__( self, records: dict[Hashable, Any], ) -> None: name_field = records.get("name-field") assert isinstance(name_field, str), f"name-field must be of type str - {type(name_field)}" self.__name_field = name_field data_field = records.get("data-field") assert isinstance(data_field, str), f"data-field must be of type str - {type(data_field)}" self.__data_field = data_field colors = records.get("colors") if isinstance(colors, list): for color in colors: assert isinstance(color, str), f"color must be of type str - {type(color)}" elif isinstance(colors, dict): for color in colors.values(): assert isinstance(color, str), f"color must be of type str - {type(color)}" elif colors is None: colors = [] elif not isinstance(colors, str): raise DemistoException(f"colors must be of type dict, list or null - {type(colors)}") self.__colors = colors @property def name_field( self, ) -> str: return self.__name_field @property def data_field( self, ) -> str: return self.__data_field @property def colors( self, ) -> dict[Hashable, str] | list[str] | str: return self.__colors class Field: def __init__( self, field: dict[Hashable, Any], default_color: str, ) -> None: assert isinstance(field, dict), f"field in y.fields must be of type dict - {type(field)}" color = field.get("color") assert isinstance(color, str) or color is None, f"field.color must be of type str or null - {type(color)}" self.__color = color or default_color self.__label = field.get("label") assert ( isinstance(self.__label, str) or self.__label is None ), f"field.label must be of type str or null - {type(self.__label)}" @property def color( self, ) -> str: return self.__color @property def label( self, ) -> str | None: return self.__label def __init__( self, y: dict[Hashable, Any], ) -> None: self.__records: Records | None = None # pylint: disable=undefined-variable self.__fields: dict[Hashable, Field] | None = None # pylint: disable=undefined-variable group = y.get("group") if group == "records": records = y.get(group) assert isinstance(records, dict), f"y.records is required and must be of type dict - {type(records)}" self.__records = self.Records(records) elif group == "fields": fields = y.get(group) assert isinstance(fields, dict), f"y.fields is required and must be of type dict - {type(fields)}" self.__fields = { name: self.Field(field, default_color=color) for (name, field), color in zip(fields.items(), EntryBuilder.list_colors(len(fields))) } else: raise DemistoException(f"y.group must be 'records' or 'fields' - {group}") @property def records( self, ) -> Records | None: return self.__records @property def fields( self, ) -> dict[Hashable, Field] | None: return self.__fields def __init__( self, template: dict[Hashable, Any], ) -> None: x = template.get("x") assert isinstance(x, dict), f"x must be of type dict - {type(x)}" self.__x = self.X(x) y = template.get("y") assert isinstance(y, dict), f"y must be of type dict - {type(y)}" self.__y = self.Y(y) @property def x( self, ) -> X: return self.__x @property def y( self, ) -> Y: return self.__y template = Template(params) if records := template.y.records: ynames = EntryBuilder.__sum_by( recordset=recordset, sum_field=records.data_field, group_by=records.name_field, order_asc=False, ) # Create color mapping ycolors = EntryBuilder.__make_color_palette( names=[x for x in ynames if isinstance(x, str)], colors=records.colors, ) # Build stats stats = [] for x_val, x_records in EntryBuilder.__enum_fields_by_group( recordset=recordset, group_by=template.x.by, sort_by=template.x.sort_by, asc=template.x.asc, ): groups = {k: None for k in ynames} xlabel = "" for y_fields in x_records: data = y_fields.get(records.data_field) name = y_fields.get(records.name_field) name_str = to_str(name) groups[name_str] = assign_params( name=name_str, data=[to_float(data)], color=ycolors.get(name), ) xlabel = xlabel or to_str(y_fields.get(template.x.field) if template.x.field else x_val) stats.append( { "name": xlabel, "groups": [ assign_params( name=k, data=[0], color=ycolors.get(k), ) if v is None else v for k, v in groups.items() ], } ) elif fields := template.y.fields: # Build stats stats = [ { "name": to_str(y_fields.get(template.x.field) if template.x.field else x_val), "groups": [ { "name": to_str(y_group.label or field or ""), "data": [to_float(y_fields.get(field))], "color": y_group.color, } for field, y_group in fields.items() ], } for x_val, x_records in EntryBuilder.__enum_fields_by_group( recordset=recordset, group_by=template.x.by, sort_by=template.x.sort_by, asc=template.x.asc, ) for y_fields in x_records ] else: stats = [] return { "Type": EntryType.WIDGET, "ContentsFormat": chart_type, "Contents": dict( assign_params(params=params.get("params")), stats=stats, ), } @staticmethod def __build_single_bar( params: dict[Hashable, Any], recordset: list[dict[Hashable, Any]], ) -> dict[Hashable, Any]: """Build a single bar entry :param params: The template parameters for 'single-bar'. :param recordset: The list of fields used to create the single-bar chart. :return: An single bar entry. """ return EntryBuilder.__build_singley_chart( chart_type="bar", params=params, recordset=recordset, ) @staticmethod def __build_stacked_bar( params: dict[Hashable, Any], recordset: list[dict[Hashable, Any]], ) -> dict[Hashable, Any]: """Build a stacked bar entry :param params: The template parameters for 'stacked-bar'. :param recordset: The list of fields used to create the stacked-bar chart. :return: An stacked bar entry. """ return EntryBuilder.__build_multiy_chart( chart_type="bar", params=params, recordset=recordset, ) @staticmethod def __build_line( params: dict[Hashable, Any], recordset: list[dict[Hashable, Any]], ) -> dict[Hashable, Any]: """Build a line entry :param params: The template parameters for 'line'. :param recordset: The list of fields used to create the line chart. :return: A line entry. """ return EntryBuilder.__build_multiy_chart( chart_type="line", params=params, recordset=recordset, ) @staticmethod def __build_pie( params: dict[Hashable, Any], recordset: list[dict[Hashable, Any]], ) -> dict[Hashable, Any]: """Build a pie entry :param params: The template parameters for 'pie'. :param recordset: The list of fields used to create the pie chart. :return: A pie chart entry. """ return EntryBuilder.__build_singley_chart( chart_type="pie", params=params, recordset=recordset, ) @staticmethod def __build_markdown_table( params: dict[Hashable, Any], recordset: list[dict[Hashable, Any]], ) -> dict[Hashable, Any]: """Build a markdown table :param params: The template parameters for 'markdown-table'. :param recordset: The list of fields used to create the markdown table. :return: A markdown table. """ class Template: class Sort: def __init__( self, sort: dict[Hashable, Any], ) -> None: self.__by = sort.get("by") assert ( isinstance(self.__by, str) or self.__by is None ), f"sort.by must be of type str or null - {type(self.__by)}" self.__asc = EntryBuilder.to_sort_order(sort.get("order") or "asc") @property def by( self, ) -> str | None: return self.__by @property def asc( self, ) -> bool: return self.__asc class Column: def __init__( self, column: dict[Hashable, Any], ) -> None: assert isinstance(column, dict), f"column in columns must be of type dict - {type(column)}" self.field = column.get("field") self.label = column.get("label") or self.field or "" def __init__( self, template: dict[Hashable, Any], ) -> None: self.__title = template.get("title") or "" assert isinstance(self.__title, str), f"title must be of type str or null - {type(self.__title)}" self.__columns: list[Column] | None = None # pylint: disable=undefined-variable if columns := template.get("columns"): assert isinstance(columns, list), f"columns must be list or null - {type(columns)}" self.__columns = [self.Column(c) for c in columns] sort = template.get("sort") or {} assert isinstance(sort, dict), f"sort must be of type dict or null - {type(sort)}" self.__sort = self.Sort(sort) @property def title( self, ) -> str: return self.__title @property def columns( self, ) -> list[Column] | None: return self.__columns @property def sort( self, ) -> Sort: return self.__sort template = Template(params) if not recordset: md = "" else: if template.sort.by: recordset = sorted( recordset, key=lambda v: SortableValue(v.get(template.sort.by)), reverse=not template.sort.asc, ) # Build markdown md = tableToMarkdown( template.title, recordset, headers=[c.field for c in template.columns] if template.columns else None, headerTransform=lambda field: next( (c.label for c in template.columns or [] if c.field == field), pascalToSpace(field.replace("_", " ")) ), sort_headers=False, ) return { "Type": EntryType.NOTE, "ContentsFormat": EntryFormat.MARKDOWN, "HumanReadable": md, "Contents": None, } @staticmethod def __build_duration( params: dict[Hashable, Any], recordset: list[dict[Hashable, Any]], ) -> dict[Hashable, Any]: """Build a duration entry :param params: The template parameters for 'duration'. :param recordset: The list of fields used to create the duration entry. :return: A duration entry. """ field = params.get("field") if not field or not isinstance(field, str): raise DemistoException(f"field must be of type str - {type(field)}") if len(recordset) > 1: raise DemistoException("The duration entry allows at most one record.") return { "Type": EntryType.WIDGET, "ContentsFormat": "duration", "Contents": dict( assign_params(params=params.get("params")), stats=int(to_float((recordset or [{}])[0].get(field))), ), } @staticmethod def __build_number( params: dict[Hashable, Any], recordset: list[dict[Hashable, Any]], ) -> dict[Hashable, Any]: """Build a number entry :param params: The template parameters for 'number'. :param recordset: The list of fields used to create the number entry. :return: A number entry. """ field = params.get("field") if not field or not isinstance(field, str): raise DemistoException(f"field must be of type str - {type(field)}") if len(recordset) > 1: raise DemistoException("The number entry allows at most one record.") return { "Type": EntryType.WIDGET, "ContentsFormat": "number", "Contents": dict( assign_params(params=params.get("params")), stats=to_float((recordset or [{}])[0].get(field)), ), } @staticmethod def __build_number_trend( params: dict[Hashable, Any], recordset: list[dict[Hashable, Any]], ) -> dict[Hashable, Any]: """Build a number trend entry :param params: The template parameters for 'number-trend'. :param recordset: The list of fields used to create the number trend entry. :return: A number trend entry. """ prev_field = params.get("prev-field") if not prev_field or not isinstance(prev_field, str): raise DemistoException(f"prev-field must be of type str - {type(prev_field)}") curr_field = params.get("curr-field") if not curr_field or not isinstance(curr_field, str): raise DemistoException(f"curr-field must be of type str - {type(curr_field)}") if len(recordset) > 1: raise DemistoException("The number-trend entry allows at most one record.") fields = (recordset or [{}])[0] return { "Type": EntryType.WIDGET, "ContentsFormat": "number", "Contents": dict( assign_params(params=params.get("params")), stats={ "prevSum": to_float(fields.get(prev_field)), "currSum": to_float(fields.get(curr_field)), }, ), } @staticmethod def __build_markdown( params: dict[Hashable, Any], **kwargs, ) -> dict[Hashable, Any]: """Build a markdown entry :param params: The template parameters for 'markdown'. :return: A markdown entry. """ return { "Type": EntryType.NOTE, "ContentsFormat": EntryFormat.MARKDOWN, "HumanReadable": str(params.get("text") or ""), "Contents": None, } @staticmethod def to_sort_order( order: str, ) -> bool: if order == "asc": return True elif order == "desc": return False else: raise DemistoException(f"Invalid sort order - {order}") @staticmethod def list_colors( n: int, ) -> list[str]: colors = [ f"rgb({int(r * 256)}, {int(g * 256)}, {int(b * 256)})" for r, g, b in [colorsys.hsv_to_rgb(i / n, 1, 0.9) for i in range(max(0, n))] ] return list(reversed(colors[1::2])) + colors[::2] def __get_default_entry( self, scope: DefaultEntryScope, entry_params: dict[Hashable, Any], ) -> dict[Hashable, Any] | None: default = entry_params.get("default") assert default is None or isinstance(default, dict), f"entry.default must be of type dict or null - {type(default)}" if not default: return None scopes = default.get("scope") assert scopes is None or isinstance( scopes, str | list ), f"default.scope must be of type null, str, or list - {type(scopes)}" scopes = [str(x) for x in list(DefaultEntryScope)] if scopes is None else scopes scopes = scopes if isinstance(scopes, list) else [scopes] if scope not in scopes: return None entry_key = str(scope) if str(scope) in default else "entry" entry = default.get(entry_key) if isinstance(entry, dict): return entry elif isinstance(entry, str): return { "Type": EntryType.NOTE, "ContentsFormat": EntryFormat.MARKDOWN, "HumanReadable": entry, "Contents": None, } else: raise DemistoException(f"default.{entry_key} must be of type stror dict - {type(entry)}") def __init__( self, formatter: Formatter, context: ContextData, ) -> None: self.__formatter = formatter self.__context = context def build( self, query: Query, entry_params: dict[Hashable, Any], ) -> tuple[dict[str, Any], dict[Hashable, Any]]: """Build an entry by querying data. :param query: The query instance. :param entry_params: Template parameters for an entry. :return: A pair of extra context and entry. """ query_params = query.query_params extra_context: dict[str, Any] = { "query": { "string": query_params.query_string, "timeframe": { "from": query_params.get_earliest_time_iso(utc=True), "to": query_params.get_latest_time_iso(utc=True), }, "request_url": ( "/xql/xql-search?phrase=" + urllib.parse.quote(query_params.query_string) + "&timeframe=" + urllib.parse.quote( json.dumps( { "from": int(query_params.earliest_time.timestamp() * 1000), "to": int(query_params.latest_time.timestamp() * 1000), } ) ) ), } } if not query.available() and ( entry := self.__get_default_entry( scope=DefaultEntryScope.QUERY_SKIPPED, entry_params=self.__formatter.build(template=entry_params, context=self.__context.inherit(extra_context)), ) ): return extra_context, entry entry_type = entry_params.get("type") if not entry_type: raise DemistoException("type is required.") params = entry_params.get(entry_type, {}) if not isinstance(params, dict): raise DemistoException(f"{entry_type} must be of type dict - {type(params)}.") build_entry = { "single-bar": lambda params, recordset: self.__build_single_bar( params=params, recordset=recordset, ), "stacked-bar": lambda params, recordset: self.__build_stacked_bar( params=params, recordset=recordset, ), "line": lambda params, recordset: self.__build_line( params=params, recordset=recordset, ), "pie": lambda params, recordset: self.__build_pie( params=params, recordset=recordset, ), "markdown-table": lambda params, recordset: self.__build_markdown_table( params=params, recordset=recordset, ), "duration": lambda params, recordset: self.__build_duration( params=params, recordset=recordset, ), "number": lambda params, recordset: self.__build_number( params=params, recordset=recordset, ), "number-trend": lambda params, recordset: self.__build_number_trend( params=params, recordset=recordset, ), "markdown": lambda params, recordset: self.__build_markdown( params=params, recordset=recordset, ), }.get(entry_type) if not build_entry: raise DemistoException(f"Invalid type - {entry_type}") execution_id, recordset = query.query() extra_context.update( { "recordset": recordset, "query": dict( extra_context.get("query") or {}, execution_id=execution_id, result_url=f"/xql/xql-search/{urllib.parse.quote(execution_id)}", ), } ) if not recordset and ( entry := self.__get_default_entry( scope=DefaultEntryScope.NO_RECORDSET, entry_params=self.__formatter.build(template=entry_params, context=self.__context.inherit(extra_context)), ) ): return extra_context, entry else: return extra_context, build_entry( self.__formatter.build(template=params, context=self.__context.inherit(extra_context)), recordset ) """ MAIN FUNCTION """ class Main: @staticmethod def __create_base_context( args: dict[Hashable, Any], ) -> dict[str, Any]: """Create the base context data. :param args: The argument parameters. :return: The base context data. """ context = args.get("context_data") if isinstance(context, str): context = json.loads(context) assert context is None or isinstance(context, dict), f"Context data must be of type str, dict, or null - {type(context)}" return dict(demisto.context(), **(context or {})) @staticmethod def __create_context( base_context: dict[str, Any], template: dict[Hashable, Any], ) -> ContextData: """Create the context data. :param base_context: The base context data. :param template: The template. :return: The context data. """ def __child_paths( parent: str, paths: list[str], ) -> list[str]: """Get a list of child node paths. :param parent: The name of the parent node. :param paths: A list of node paths. :return: A list of relative paths under the given parent node. """ cpaths = [] for path in paths: pit = iter(path) nit = (next(pit, "\\") if c == "\\" else "" if c == "." else c for c in pit) if parent == "".join(itertools.takewhile(lambda x: x != "", iter(nit))): cpaths.append("".join(list(pit))) return cpaths def __filter_context( context: Any, filters: list[str], ) -> Any: """Keep specific values in the context based on the given filters. :param context: The context node. :param filters: A list of node paths to be removed from the context. :return: The filtered context node. """ if any(v == "" for v in filters): return context elif isinstance(context, dict): return { k: v for k, cpaths in ((k, __child_paths(k, filters)) for k in context) if (v := __filter_context(context[k], cpaths)) or any(c == "" for c in cpaths) } or None elif isinstance(context, list): return [v for v in (__filter_context(v, filters) for v in context) if v] or None else: return None def __remove_null( context: Any, paths: list[str] | None, entries_only: bool, ) -> Any: """Remove key-value pairs from the context where the value is None. :param context: The context node. :param paths: A list of node paths to be removed from the context where the value is None. :param entries_only: Set to True to keep `None` values when they are not associated with keys, otherwise set to False to remove them. :return: The updated context node with key-value pairs removed. """ if isinstance(context, dict): if paths is None: return {k: v for k, v in context.items() if v is not None} else: return { k: __remove_null(v, cpaths, entries_only) for k, v in context.items() if (not (cpaths := __child_paths(k, paths)) or v is not None or all(path != "" for path in cpaths)) } elif isinstance(context, list): return [__remove_null(v, paths, entries_only) for v in context if entries_only or v is not None] else: return context def __apply_default( context: Any, default: dict[str, Any], ) -> Any: """Create a context node with default parameters. :param context: The context node. :param default: The default parameters to apply. :return: The context node with the applied default parameters. """ if isinstance(context, dict): context = dict(context) for k, dv in default.items(): if k in context: if isinstance(dv, dict): context[k] = __apply_default(context.get(k), dv) else: context[k] = dv return context elif isinstance(context, list): return [__apply_default(x, default) for x in context] else: return context fields = demisto.incident() fields = dict(fields, **(fields.get("CustomFields") or {})) fields.pop("CustomFields", None) entries = { "context": base_context, "alert": fields if (is_xsiam() or is_platform()) else None, "issue": fields if is_platform() else None, "incident": None if (is_xsiam() or is_platform()) else fields, } for name, context in dict(entries).items(): filters = demisto.get(template, f"config.{name}.filters") if filters is not None: assert isinstance(filters, list), f"config.{name}.filters must be of type null or list - {type(filters)}" filters = [f for f in filters if isinstance(f, str)] context = __filter_context(context, filters) or {} remove_null = demisto.get(template, f"config.{name}.remove-null") if remove_null is not None: assert isinstance( remove_null, dict ), f"config.{name}.remove-null must be of type null or dict - {type(remove_null)}" entries_only = argToBoolean(remove_null.get("entries-only", "true")) paths = remove_null.get("paths") if paths is not None: assert isinstance( paths, list ), f"config.{name}.remove-null.paths must be of type null or list - {type(paths)}" paths = [x for x in paths if isinstance(x, str)] context = __remove_null(context, paths, entries_only) or {} default = demisto.get(template, f"config.{name}.default") if default is not None: assert isinstance(default, dict), f"config.{name}.default must be of type dict - {type(default)}" context = __apply_default(context, default) or {} entries[name] = context return ContextData( context=entries.get("context"), alert=entries.get("alert"), issue=entries.get("issue"), incident=entries.get("incident"), ) @staticmethod def __get_template( args: dict[Hashable, Any], ) -> tuple[str, dict[Hashable, Any]]: """Get the templates with its name :param args: The argument parameters. :return: The template name and template. """ templates_type = args.get("templates_type") or "raw" if templates_type in ("raw", "base64"): templates = args.get("templates") if templates_type == "base64": assert isinstance( templates, str ), f"'templates' must be of type str in templates_type = 'base64' - {type(templates)}" templates = base64.b64decode(templates.encode()).decode() elif templates_type == "list": templates = execute_command("getList", {"listName": args.get("templates")}) else: raise DemistoException(f"Invalid template type - {templates_type}") if isinstance(templates, str): if argToBoolean(args.get("triple_quotes_to_string", "true")): templates = re.sub(r"""(\"{3}|'{3}|`{3})(.*?)\1""", lambda m: json.dumps(m.group(2)), templates, flags=re.DOTALL) templates = json.loads(templates) if not isinstance(templates, dict): raise DemistoException(f"Invalid templates - {templates}") template_name = args.get("template_name") or "" if template := templates.get(template_name): if not isinstance(template, dict): raise DemistoException(f"Invalid template - {template}") else: raise DemistoException(f"No templates were found - {template_name}") return template_name, template @staticmethod def __parse_date_time( value: Any, base_time: datetime | None, ) -> datetime: """Parse a date time value :param value: The date or time to parse :param base_time: The base time for the relative time. :return: aware datetime object """ if value in (None, ""): return datetime.now(pytz.UTC) if isinstance(value, int): # Parse as time stamp try: # Considered the value as seconds > milliseconds > microseconds when it's too large (> uint max). # (Currently later than 2106-02-07 06:28:15) while value > 4294967295: value /= 1000 return datetime.fromtimestamp(value).astimezone(pytz.UTC) except Exception as e: raise DemistoException(f"Error with input date / time - {e}") if isinstance(value, str): value = value.strip() try: # Parse as time stamp return Main.__parse_date_time(int(value), base_time) except (TypeError, ValueError): pass try: date_time = dateparser.parse(value, settings=assign_params(RELATIVE_BASE=base_time)) assert date_time is not None, f"could not parse {value}" if date_time.tzinfo is not None: return date_time date_time = dateparser.parse( value, settings=assign_params( TIMEZONE="UTC", RETURN_AS_TIMEZONE_AWARE=True, RELATIVE_BASE=base_time, ), ) assert date_time is not None, f"could not parse {value}" return date_time except Exception as e: raise DemistoException(f"Error with input date / time - {e}") @staticmethod def __get_variable_substitution( args: dict[Hashable, Any], template: dict[Hashable, Any], ) -> tuple[str, str]: vs = args.get("variable_substitution") or "${,}" if isinstance(vs, str): vs = vs.split(",", maxsplit=1) elif not isinstance(vs, list): raise DemistoException(f"Invalid variable substitution - {vs}") if not vs or not vs[0]: raise DemistoException("variable_substitution must have a opening marker.") elif len(vs) == 1: assert isinstance(vs[0], str), f"opening marker must be of type str - {vs[0]}" vs = [vs[0], ""] elif len(vs) == 2: assert isinstance(vs[0], str), f"opening marker must be of type str - {vs[0]}" assert isinstance(vs[1], str), f"closing marker must be of type str - {vs[1]}" else: raise DemistoException(f"too many values for variable_substitution - {vs}") var_opening = demisto.get(template, "config.variable_substitution.opening") if var_opening is not None: assert isinstance(var_opening, str), f"config.variable_substitution.opening must be of type str - {var_opening}" assert var_opening, "config.variable_substitution.opening cannot be empty when provided" else: var_opening = vs[0] var_closing = demisto.get(template, "config.variable_substitution.closing") if var_closing is not None: assert isinstance(var_closing, str), f"config.variable_substitution.closing must be of type str - {var_opening}" else: var_closing = vs[1] return str(var_opening), str(var_closing) @staticmethod def __get_base_time( args: dict[Hashable, Any], query_node: dict[Hashable, Any], context: ContextData, ) -> tuple[datetime, datetime]: """Get the base time of earliest_time and latest_time :param args: The argument parameters. :param query_node: The `.query` node of the template. :param context: The context data. :return: The base time of earliest_time and latest_time. """ round_time = demisto.get(query_node, "time_range.round_time") if round_time is None: round_time = argToList(args.get("round_time")) if len(round_time) == 0: round_time_latest = round_time_earliest = 0 elif len(round_time) == 1: round_time_latest = round_time_earliest = arg_to_number(round_time[0]) or 0 elif len(round_time) == 2: round_time[0] = arg_to_number(round_time[0]) assert isinstance(round_time[0], int), f"list of round_time must be number - {round_time}" round_time_earliest = round_time[0] round_time[1] = arg_to_number(round_time[1]) assert isinstance(round_time[1], int), f"list of round_time must be number - {round_time}" round_time_latest = round_time[1] else: raise DemistoException(f"Too many round_time - {round_time}") elif isinstance(round_time, dict): round_time_earliest = arg_to_number(round_time.get("earliest_time")) or 0 round_time_latest = arg_to_number(round_time.get("latest_time")) or 0 elif isinstance(round_time, str | int): round_time_latest = round_time_earliest = arg_to_number(round_time) or 0 else: raise DemistoException(f"query.time_range.round_time must be of type str or dict - {round_time}") base_time = args.get("base_time") if not base_time: # Set default base time for k in [ "issue.occurred", "alert.occurred", "incident.occurred", "issue.created", "alert.created", "incident.created", ]: base_time = context.get(k) if base_time and base_time != "0001-01-01T00:00:00Z": break else: base_time = "now" base_time = Main.__parse_date_time(base_time, None) return ( base_time if not round_time_earliest else datetime.fromtimestamp( math.floor(base_time.timestamp() / round_time_earliest) * round_time_earliest, base_time.tzinfo ), base_time if not round_time_latest else datetime.fromtimestamp( math.floor(base_time.timestamp() / round_time_latest) * round_time_latest, base_time.tzinfo ), ) @staticmethod def __build_query_params( args: dict[Hashable, Any], query_name: str, query_node: dict[Hashable, Any], context: ContextData, ) -> QueryParams: """Build query parameters :param args: The argument parameters. :param query_name: The name of the query. :param query_node: The node of the query information. :param context: The context data. :return: Query parameters. """ earliest_time_base, latest_time_base = Main.__get_base_time( args=args, query_node=query_node, context=context, ) if not (query_string := query_node.get("xql")): raise DemistoException("Query string is required.") return QueryParams( query_name=query_name, query_string=query_string, earliest_time=Main.__parse_date_time( demisto.get(query_node, "time_range.earliest_time", args.get("earliest_time", "24 hours ago")), earliest_time_base ), latest_time=Main.__parse_date_time( demisto.get(query_node, "time_range.latest_time", args.get("latest_time", "now")), latest_time_base ), ) @staticmethod def __create_lock( query_node: dict[Hashable, Any], ) -> CoreLock | None: """Create a locking instance if possible. :param query_node: The node of the query information. :return: A lock instance. """ if locking := demisto.get(query_node, "locking"): module = locking.get("module") or "core-lock" timeout = locking.get("timeout") return CoreLock( module=module, name=locking.get("name"), info=locking.get("info"), timeout=None if timeout is None else arg_to_number(timeout), using=locking.get("using"), ) return None def __arg_to_int( self, name: str, default_value: int, ) -> int: arg = self.__args.get(name) if arg is None or arg == "": return default_value elif isinstance(arg, str | int | float): try: return int(arg) except Exception: raise DemistoException(f"Invalid {name} - {arg}") else: raise DemistoException(f"Invalid {name} - {arg}") def __is_query_executable( self, ) -> bool: class Evaluation: def __init__( self, formatter: Formatter, context: ContextData, ) -> None: self.__formatter = formatter self.__context = context def evaluate( self, conds: Any, ) -> bool: if isinstance(conds, str): conds = self.__formatter.build( template=conds, context=self.__context, ) if isinstance(conds, dict): return any(self.evaluate(k) and self.evaluate(v) for k, v in conds.items()) elif isinstance(conds, list): return any(self.evaluate(v) for v in conds) elif conds is None: return False elif isinstance(conds, bool): return conds elif isinstance(conds, int | float): return conds != 0.0 elif isinstance(conds, str): return conds.lower() not in ("", "false") else: return bool(conds) query = self.__template.get("query") or {} if "conditions" not in query: # Queries are executable when 'query.conditions' is not specified return True else: conditions = query.get("conditions") return Evaluation( formatter=Formatter( variable_substitution=self.__variable_substitution, keep_symbol_to_null=False, ), context=self.__context, ).evaluate(conditions) def __init__( self, args: dict[Hashable, Any], ) -> None: self.__args = args self.__template_name, self.__template = self.__get_template(args) self.__variable_substitution = self.__get_variable_substitution(args, self.__template) context = self.__create_base_context(args) self.__context: ContextData = self.__create_context(context, self.__template) self.__cache_repo = demisto.get(context, Cache.CACHE_ROOT_KEY) self.__cache_type: str = args.get("cache_type") or CacheType.RECORDSET if self.__cache_type not in [str(x) for x in list(CacheType)]: raise DemistoException(f"Invalid cache_type - {self.__cache_type}") self.__max_retries: int = self.__arg_to_int("max_retries", DEFAULT_RETRY_MAX) # max_retries accepts 0 self.__retry_interval: int = self.__arg_to_int("retry_interval", DEFAULT_RETRY_INTERVAL) or DEFAULT_RETRY_INTERVAL self.__polling_interval: int = self.__arg_to_int("polling_interval", DEFAULT_POLLING_INTERVAL) or DEFAULT_POLLING_INTERVAL self.__query_timeout_duration: int = ( self.__arg_to_int("query_timeout_duration", DEFAULT_QUERY_TIMEOUT_DURATION) or DEFAULT_QUERY_TIMEOUT_DURATION ) self.__output_recordset = argToBoolean(args.get("output_recordset", "false")) self.__xql_query_instance: str | None = demisto.get(self.__template, "query.command.using") or args.get( "xql_query_instance" ) def create( self, ) -> CommandResults: """Create a graph entry :return: The command results. """ formatter = Formatter( variable_substitution=self.__variable_substitution, keep_symbol_to_null=True, ) query_node = formatter.build( template=demisto.get(self.__template, "query"), context=self.__context, ) query_params = self.__build_query_params( args=self.__args, query_name=self.__template_name, query_node=query_node, context=self.__context, ) cache = Cache(name=self.__template_name, repo=self.__cache_repo) entry = cache.load_entry(query_params.query_hash()) if self.__cache_type == CacheType.ENTRY else None need_query = not entry and self.__is_query_executable() extra_context: dict[str, Any] = {} if not entry: extra_context, entry = EntryBuilder( formatter=formatter, context=self.__context, ).build( query=Query( query_params=query_params, xql_query=XQLQuery( xql_query_instance=self.__xql_query_instance, polling_interval=self.__polling_interval, retry_interval=self.__retry_interval, retry_max=self.__max_retries, query_timeout_duration=self.__query_timeout_duration, core_lock=self.__create_lock(query_node), ) if need_query else None, cache=cache if self.__cache_type != CacheType.NONE else None, ), entry_params=self.__template.get("entry") or {}, ) res = { "QueryParams": { "query_name": query_params.query_name, "query_string": query_params.query_string, "earliest_time": query_params.get_earliest_time_iso(), "latest_time": query_params.get_latest_time_iso(), }, "QueryHash": query_params.query_hash(), "RequestURL": demisto.get(extra_context, "query.request_url"), "ResultURL": demisto.get(extra_context, "query.result_url"), "ExecutionID": demisto.get(extra_context, "query.execution_id"), "RecordSet": demisto.get(extra_context, "recordset") or [] if self.__output_recordset else [], "Entry": entry, } if need_query and self.__cache_type == CacheType.ENTRY: cache.save_entry(query_params, entry) return CommandResults( readable_output="Done.", outputs={"XQLDSHelper": res}, raw_response=res, ) def main(): try: return_results(Main(demisto.args()).create()) except Exception as e: return_error(f"{e}\n\n{traceback.format_exc()}") """ ENTRY POINT """ if __name__ in ("__main__", "__builtin__", "builtins"): main()
README
Run an XQL query and creates an entry for the General Purpose Dynamic Section to display a graph or table widget based on the results.
The query is executed by the xdr-xql-generic-query and the xdr-xql-get-query-results command.
Script Data
| Name | Description |
|---|---|
| Script Type | python3 |
| Tags | Utility |
Inputs
| Argument Name | Description |
|---|---|
| templates_type | The type of the templates data. |
| template_name | The name of a template to choose it from ‘templates’. |
| templates | A list of templates to choose from for building an entry. |
| base_time | The base time for the relative time provided to earliest_time or latest_time (The default is the first available value from the following: issue.occurred, alert.occurred, incident.occurred, issue.created, alert.created, incident.created, now). |
| round_time | The value (in seconds) used to round down the base time (Default = 0). If two parameters are provided in a list, they will be applied to the base time for earliest_time and latest_time, respectively. |
| earliest_time | The earliest time at which the time range of the query starts (Default = 24 hours ago). |
| latest_time | The latest time at which the time range of the query ends (Default = now). |
| variable_substitution | The pair of default opening and closing markers that enclose a variable name (Default = ${,}). |
| triple_quotes_to_string | Set to true to convert a string within triple quotes in the templates to a JSON string if it is of type string. Set to false to use the templates as they are, without any conversion. Both triple single quotes, triple double quotes and triple backquotes are supported. (Default = true) |
| cache_type | The name of the type that defines which data is stored and retrieved from the cache to create the entry (Default = recordset). |
| max_retries | The maximum number of retries to query XQL for recoverable errors (Default = 10). |
| retry_interval | The wait time (in seconds) between retries (Default = 10). |
| polling_interval | The polling interval (in seconds) to wait for results (Default = 10). |
| query_timeout_duration | The maximum duration (in seconds) allowed for an XQL query to complete after it has started (Default = 60). |
| context_data | The custom context data is merged with the current context data for use. |
| output_recordset | Set to true to return the recordset in the outputs context; otherwise, set to false. |
| xql_query_instance | The name of the integration instance to execute xdr-xql-generic-query and xdr-xql-get-query-results. |
Query Execution Timeout and Retry Limits
Due to limitations on the number of XQL queries that can run in parallel, the script will poll at intervals of retry_interval seconds until the query becomes available to execute, with a maximum of max_retries attempts.
Once the query starts running, it may take some time to return results. During this period, the script will continue polling at intervals of polling_interval seconds while waiting for the results. If the results are not returned within the query_timeout_duration seconds, the query will be treated as an error.
Outputs
| Path | Description | Type |
|---|---|---|
| XQLDSHelper.QueryParams | The query parameters. | unknown |
| XQLDSHelper.QueryHash | The hash value of the query parameters. | string |
| XQLDSHelper.RequestURL | The URL path, including query parameters, used to search datasets in the XQL builder. | string |
| XQLDSHelper.ResultURL | The URL path used to get the results of an executed query in the XQL builder. | string |
| XQLDSHelper.ExecutionID | The unique execution ID for the request query. | string |
| XQLDSHelper.RecordSet | The record set retrieved by the XQL query. | unknown |
| XQLDSHelper.Entry | The entry data for the general dynamic section. | unknown |
Structure of the templates
The data provided to templates depends on the parameter specified in templates_type.
- templates_type:
raw- Specifies the list of templates directly. The details will be provided later.
- templates_type:
base64- Specifies the list of templates encoded directly in base64. The details will be provided later.
- templates_type:
list- Specifies the name of a list managed in the
Listsof Cortex XSIAM/XSOAR. The list of templates is retrieved from it.
- Specifies the name of a list managed in the
The list of templates must be either a dictionary or a serialized JSON string.
It can contain multiple templates, and a summary of the structure is provided below.
{
<template_name>: <template>,
:
}
A template must be a dictionary node with two mandatory dictionary nodes (query and entry) and an optional dictionary node (config).
The summary of the template structure in the templates is provided below.
{
<template_name>: {
"config": {
"variable_substitution": {
"opening": "<opening marker>",
"closing": "<closing marker>"
},
"context": {
"filters": <filter paths>,
"default": <default values>,
"remove-null": {
"entries-only": <ctrl flag>,
"paths": <target paths>
}
},
"alert": {
"filters": <filter paths>,
"default": <default values>,
"remove-null": {
"entries-only": <ctrl flag>,
"paths": <target paths>
}
},
"issue": {
"filters": <filter paths>,
"default": <default values>,
"remove-null": {
"entries-only": <ctrl flag>,
"paths": <target paths>
}
},
"incident": {
"filters": <filter paths>,
"default": <default values>,
"remove-null": {
"entries-only": <ctrl flag>,
"paths": <target paths>
}
}
},
"query": {
"xql": "<query string>",
"command": {
"name": "<command name>",
"using": "<instance name>"
},
"time_range": {
"earliest_time": "<earliest time>",
"latest_time": "<latest time>",
"round_time": <round time>
},
"conditions": <conditions>,
"locking": {
"module": "<locking module>",
"name": "<name of lock>",
"info": "<info for lock>",
"timeout": <timeout>,
"using": "<instance name>"
}
},
"entry": {
"type": "<widget type>",
"<widget type>": <widget type dependent data>
"default": {
"scope": "<scope>",
"entry": <default entry>,
"no_recordset": <default entry>,
"query_skipped": <default entry>
}
}
}
:
}
Node: config
| Path | Description | Type |
|---|---|---|
| .config.variable_substitution.opening | [Optional] The opening marker of the enclosure for variable substitution. It overrides the opening marker specified in the variable_substitution parameter of the arguments. |
String |
| .config.variable_substitution.closing | [Optional] The closing marker of the enclosure for variable substitution. It overrides the opening marker specified in the variable_substitution parameter of the arguments. |
String |
| .config.context.filters | [Optional] A list of node paths to extract from the context data for use. The filters are applied before the default parameters are applied. | List |
| .config.context.default | [Optional] Default parameters to use when not present in the context data. | Dict |
| .config.context.remove-null.entries-only | [Optional] Set to True to remove only dictionary entries; set to false to remove both dictionary entries and null values in lists (Default = true). | Boolean |
| .config.context.remove-null.paths | [Optional] A list of node paths to remove dictionary entries or values from the context data before applying default parameters. | List |
| .config.alert.filters | [Optional] A list of node paths to extract from the alert for use. alert. prefix is not required. The filters are applied before the default parameters are applied. |
List |
| .config.alert.default | [Optional] Default parameters to use when not present in the alert. | Dict |
| .config.alert.remove-null.entries-only | [Optional] Set to True to remove only dictionary entries; set to false to remove both dictionary entries and null values in lists (Default = true). | Boolean |
| .config.alert.remove-null.paths | [Optional] A list of node paths to remove dictionary entries or values from the alert before applying default parameters. | List |
| .config.issue.filters | [Optional] A list of node paths to extract from the issue for use. issue. prefix is not required. The filters are applied before the default parameters are applied. |
List |
| .config.issue.default | [Optional] Default parameters to use when not present in the issue. | Dict |
| .config.issue.remove-null.entries-only | [Optional] Set to True to remove only dictionary entries; set to false to remove both dictionary entries and null values in lists (Default = true). | Boolean |
| .config.issue.remove-null.paths | [Optional] A list of node paths to remove dictionary entries or values from the issue before applying default parameters. | List |
| .config.incident.filters | [Optional] A list of node paths to extract from the incident for use. incident. prefix is not required. The filters are applied before the default parameters are applied. |
List |
| .config.incident.default | [Optional] Default parameters to use when not present in the incident. | Dict |
| .config.incident.remove-null.entries-only | [Optional] Set to True to remove only dictionary entries; set to false to remove both dictionary entries and null values in lists (Default = true). | Boolean |
| .config.incident.remove-null.paths | [Optional] A list of node paths to remove dictionary entries or values from the incident before applying default parameters. | List |
Below is a sample of the config node.
"config": {
"variable_substitution": {
"opening": "{",
"closing": "}"
},
"context": {
"filters": [
"User",
"time_format"
],
"remove-null": {
"entries-only": true,
"paths": [
"User.ID"
]
},
"default": {
"User": {
"ID": "Administrator"
},
"time_format": "%b %d, %Y"
}
},
"alert": {
"default": {
"username": "Administrator"
}
},
"issue": {
"default": {
"username": "Administrator"
}
},
"incident": {
"default": {
"username": "Administrator"
}
}
}
.filters, .remove-null, and .default settings under content, alert, issue, and incident can help minimize the data used for Variable Substitution and provide default values.
Using Variable Substitution to pass the entire data to variables like ${.=val.username} is time-consuming. By using these settings to minimize the data and define default values, you can reduce the need to pass the entire data, thereby improving processing performance.
Node: query
| Path | Description | Type |
|---|---|---|
| .query.xql | The XQL query string to retrieve the record set to create an entry. | String |
| .query.command.using | [Optional] The name of the integration instance to execute the XQL query command. It overrides xql_query_instance in the argument parameters. |
String |
| .query.time_range.earliest_time | [Optional] The earliest time at which the time range of the query starts. It overrides earliest_time in the argument parameters. |
String or Number |
| .query.time_range.latest_time | [Optional] The latest time at which the time range of the query ends. It overrides latest_time in the argument parameters. |
String or Number |
| .query.time_range.round_time | [Optional] The value (in seconds) used to round down the base time. If the value is of type dict, .query.time_range.round_time.earliest_time and .query.time_range.round_time.latest_time can be provided. This parameter overrides round_time in the argument parameters. |
String, Number or Dict |
| .query.time_range.round_time.earliest_time | [Optional] The value (in seconds) used to round down the base time for earliest_time. |
String or Number |
| .query.time_range.round_time.latest_time | [Optional] The value (in seconds) used to round down the base time for latest_time. |
String or Number |
| .query.conditions | [Optional] Conditions for executing XQL: it will only be executed if the conditions evaluate to true or are not specified. If the conditions evaluate to false, the .entry.default will be applied if it is specified and the conditions defined for it are satisfied, otherwise, an empty record set will be returned. |
Any |
| .query.locking.module | [Optional] The locking module to use. Must be either core-lock or demisto-lock (Default = core-lock). |
String |
| .query.locking.name | [Optional] The lock name passed as the name parameter to the core-lock-get command. |
String |
| .query.locking.info | [Optional] Additional information provided for the lock instance via the info parameter of the core-lock-get command. |
String |
| .query.locking.timeout | [Optional] The timeout value (in seconds) passed to the timeout parameter of the core-lock-get command. |
String or Number |
| .query.locking.using | [Optional] The name of the Core Lock integration instance to execute the core-lock-get and core-lock-release. |
String |
This node supports Variable Substitution for all parameters.
Node: .query.conditions
The .query.conditions are evaluated as either true or false. The values of the conditions can be of any type in JSON (null, boolean, number, string, list, or dictionary).
null, false (of type boolean or string), 0 (of type number), and an empty string (i.e., a string with 0 length) will be treated as false. All other values will be treated as true for primitive data types.
A list or dictionary can represent a logical expression.
-
A list represents an
ORcondition, where each element in the list is treated as a separate condition.
For example, the list[X, Y, Z]is evaluated asX OR Y OR Z.
If any of the elements are true, the entire expression is considered true.
If the list is empty, it is treated as false. -
A dictionary represents a logical expression combining
ANDandORconditions.
Each key-value pair in the dictionary is treated as anANDcondition.
The dictionary as a whole is evaluated as a combination of theseANDconditions, connected byOR.
For example, consider the following dictionary:
{
Condition-A: Condition-B,
Condition-X: Condition-Y
}
This dictionary is evaluated as (Condition-A AND Condition-B) OR (Condition-X AND Condition-Y).
If any of the individual AND conditions evaluate to true, the entire dictionary expression will be true.
If the dictionary is empty, it is treated as false.
In addition, a dictionary can be nested. For example, consider the following dictionary:
{
Condition-A: {
Condition-B: [
Condition-X,
Condition-Y
]
}
}
This dictionary is evaluated as Condition-A AND Condition-B AND (Condition-X OR Condition-Y).
Node: entry
| Path | Description | Type |
|---|---|---|
| .entry.type | The name of the entry type, which must be one of the following: markdown, markdown-table, number, number-trend, pie, line, single-bar, stacked-bar or duration. | String |
| .entry.markdown | [Entry-dependent parameters] This node is required only when markdown is set in .entry.type. |
Dict |
| .entry.markdown-table | [Entry-dependent parameters] This node is required only when markdown-table is set in .entry.type. |
Dict |
| .entry.number | [Entry-dependent parameters] This node is required only when number is set in .entry.type. |
Dict |
| .entry.number-trend | [Entry-dependent parameters] This node is required only when number-trend is set in .entry.type. |
Dict |
| .entry.pie | [Entry-dependent parameters] This node is required only when pie is set in .entry.type. |
Dict |
| .entry.line | [Entry-dependent parameters] This node is required only when line is set in .entry.type. |
Dict |
| .entry.single-bar | [Entry-dependent parameters] This node is required only when single-bar is set in .entry.type. |
Dict |
| .entry.stacked-bar | [Entry-dependent parameters] This node is required only when stacked-bar is set in .entry.type. |
Dict |
| .entry.duration | [Entry-dependent parameters] This node is required only when duration is set in .entry.type. |
Dict |
| .entry.default | [Optional] The default entry settings. | Dict |
The .entry.type specifies the type of the entry, as shown below.
| Entry Type | Entry | Image |
|---|---|---|
| markdown | Markdown | ![]() |
| markdown-table | Markdown Table | ![]() |
| number | Number | ![]() |
| number-trend | Number Trend | ![]() |
| pie | Pie Chart | ![]() |
| line | Line Chart | ![]() |
| single-bar | Single-Bar Chart | ![]() |
| stacked-bar | Stacked-Bar Chart | ![]() |
| duration | Time duration | ![]() |
This node supports Variable Substitution for all parameters.
Node: entry.markdown
| Path | Description | Type |
|---|---|---|
| .text | The markdown text to display. | String |
A sample structure of markdown in an entry node is shown below.
"entry": {
"type": "markdown",
"markdown": {
"text": "## ${.recordset=>val[0].text}"
},
"default": {
"entry": "## No data"
}
}
Node: entry.markdown-table
| Path | Description | Type |
|---|---|---|
| .title | [Optional] The title of the markdown table. | String |
| .sort.by | [Optional] The name of the field by which to sort the record set. | String |
| .sort.order | [Optional] The sort order. Specifies either asc (default) for ascending or desc for descending. |
String |
| .columns | [Optional] A list of table columns in the specified order. If not specified, all fields in the record set are displayed in the table. | List |
| .columns.field | [Optional] The name of the field used to display the field value. | String |
| .columns.label | [Optional] The label for the column. If not specified, the field name is used as the label. | String |
A sample structure of markdown-table in an entry node is shown below.
"entry": {
"type": "markdown-table",
"markdown-table": {
"title": "Top 10 Applications",
"sort": {
"by": "count",
"order": "asc"
},
"columns": [
{
"field": "app",
"label": "Application"
},
{
"field": "count",
"label": "# of sessions"
}
]
},
"default": {
"entry": "## No data"
}
}
That is intended to create a table of the top 10 applications from the record set, as shown below, retrieved by the XQL query.
| app | count |
|---|---|
| netbios-dg | 145486 |
| msrpc-base | 80914 |
| web-browsing | 122016 |
| incomplete | 2479222 |
| ldap | 81384 |
| dns-base | 847200 |
| netbios-ns | 145722 |
| ms-ds-smbv3 | 80879 |
| ssl | 149358 |
| google-base | 240229 |
The table will be created as shown below:
Top 10 Applications
| Application | # of sessions |
|---|---|
| ms-ds-smbv3 | 80879 |
| msrpc-base | 80914 |
| ldap | 81384 |
| web-browsing | 122016 |
| netbios-dg | 145486 |
| netbios-ns | 145722 |
| ssl | 149358 |
| google-base | 240229 |
| dns-base | 847200 |
| incomplete | 2479222 |
Node: entry.number
| Path | Description | Type |
|---|---|---|
| .field | The name of the field used to display the field value. | String |
| .params | [Optional] The parameters provided to Contents.params in the widget entry. |
Dict |
The structure of a number widget entry is shown below for the General Purpose Dynamic Section.
The .params is passed to Contents.params.
For more details, refer to the Cortex XSIAM/XSOAR admin guide.
{
"Type": 17,
"ContentsFormat": "number",
"Contents": {
"stats": 53,
"params": <.params>
}
}
A sample structure of number in an entry node is shown below.
"entry": {
"type": "number",
"number": {
"field": "n",
"params": {
"name": "Sample Number",
"sign": "@",
"colors": {
"type": "above",
"items": {
"#FF0000": {
"value": 300
},
"#FFFF00": {
"value": 500
},
"green": {
"value": 1000
}
}
}
}
}
}
That is intended to create a number widget from the record set, as shown below, retrieved by the XQL query.
| n |
|---|
| 1234 |
The widget will be displayed as shown below.

The record set must contain at most one record for the number widget entry. An error is raised if it contains more than one record.
Node: entry.number-trend
| Path | Description | Type |
|---|---|---|
| .prev-field | The name of the field used to track the value before the change or update. | String |
| .curr-field | The name of the field that represents the current value after the change or update. | String |
| .params | [Optional] The parameters provided to Contents.params in the widget entry. |
Dict |
number-trend is a variation of the number widget entry, and will create the structure shown below for the General Purpose Dynamic Section.
The .params is passed to Contents.params.
For more details, refer to the Cortex XSIAM/XSOAR admin guide.
{
"Type": 17,
"ContentsFormat": "number",
"Contents": {
"stats": {
"prevSum": 53,
"currSum": 60
},
"params": <.params>
}
}
A sample structure of number-trend in an entry node is shown below.
"entry": {
"type": "number-trend",
"number-trend": {
"prev-field": "last_n",
"curr-field": "curr_n",
"params": {
"name": "Sample Number Trend",
"sign": "@",
"colors": {
"type": "above",
"items": {
"#00CD33": {
"value": 1000
},
"#FAC100": {
"value": 1500
},
"green": {
"value": 2000
}
}
}
}
}
}
That is intended to create a number-trend widget from the record set, as shown below, retrieved by the XQL query.
| last_n | curr_n |
|---|---|
| 1000 | 2000 |
The widget will be displayed as shown below.

The record set must contain at most one record for the number-trend widget entry. An error is raised if it contains more than one record.
Node: entry.pie
| Path | Description | Type |
|---|---|---|
| .group | Specifies records or fields. The details will be provided later in this section. |
String |
| .records | [Group-dependent parameters] This node is required only when records is set in .group. |
Dict |
| .fields | [Group-dependent parameters] This node is required only when fields is set in .group. |
Dict |
| .params | [Optional] The parameters provided to Contents.params in the widget entry. |
Dict |
.group
When specifying records for .group, it indicates that each record represents a slice in the pie chart, with the corresponding legend label.
For example, given the following data:
| app | count |
|---|---|
| dns-base | 847200 |
| ssl | 149358 |
| netbios-dg | 145486 |
| ms-ds-smbv3 | 80879 |
| google-base | 240229 |
| incomplete | 2479222 |
| web-browsing | 122016 |
| ldap | 81384 |
| msrpc-base | 80914 |
| netbios-ns | 145722 |
The count values are displayed on the slices, with each corresponding app (e.g., dns-base, ssl, netbios-dg) shown as a legend label.
In this case, the settings must be configured under the .records node, as shown below:
| Path | Description | Type |
|---|---|---|
| .records.name-field | The name of the field used to display the legend labels for each slice in the pie chart. | String |
| .records.data-field | The name of the field that represents the summed value for each slice. | String |
| .records.colors | [Optional] The color configuration for each slice. For more details, see .colors. | List, Dict, or String |
| .records.sort.by | [Optional] The name of the field by which to sort the record set. If not specified, the value of .records.data-field will be used by default. |
String |
| .records.sort.order | [Optional] The sort order. Specifies either asc (default) for ascending or desc for descending. |
String |
Below is a sample XQL query and the corresponding records structure in the entry node.
dataset = panw_ngfw_traffic_raw
| filter app != null
| comp approx_top(app, 10) as apps
| arrayexpand apps
| alter app = apps->value, count = to_integer(apps->count)
| fields app, count
"pie": {
"group": "records",
"records": {
"name-field": "app",
"data-field": "count",
"sort": {
"by": "count",
"order": "desc"
}
}
}
This is a sample pie chart created using those settings.

When specifying fields for .group, it indicates that each field’s value is represented as a separate slice in the pie chart.
In this case, the values for each field are displayed individually, and each field is associated with its own distinct legend label.
For example, given the following data:
| total_bytes_sent | total_bytes_received |
|---|---|
| 1023405 | 5050643 |
The total_bytes_sent and total_bytes_received fields will each be displayed as separate slices in the pie chart.
Each slice will be labeled accordingly, with the legend reflecting the field names, such as total_bytes_sent and total_bytes_received.
In this case, the settings must be configured under the .fields node, as shown below:
| Path | Description | Type |
|---|---|---|
| .fields | A dictionary that defines the fields used for the pie chart slices, where each key represents a field name. | Dict |
| .fields.<field-name>.label | [Optional] The label to be displayed in the legend for the slices. If not specified, the field name will be used as the label. | String |
| .fields.<field-name>.color | [Optional] [Optional] The color palette for the slices. If not specified, the default color will be applied. | String |
Below is a sample XQL query and the corresponding fields structure in the entry node.
dataset = panw_ngfw_traffic_raw
| dedup session_id
| comp sum(bytes_sent) as total_bytes_sent, sum(bytes_received) as total_bytes_received
| fields total_bytes_sent, total_bytes_received
"pie": {
"group": "fields",
"fields": {
"total_bytes_sent_mb": {
"label": "Total Bytes Sent",
"color": "rgb(122, 204, 0)"
},
"total_bytes_received_mb": {
"label": "Total Bytes Received",
"color": "rgb(0, 122, 204)"
}
}
}
Each key defined in the .fields is plotted as a separate slice in the pie chart, in the order they are specified.
For example, in the above configuration, total_bytes_sent_mb will be displayed as the first slice, and total_bytes_received_mb will be displayed as the second slice.
This is a sample pie chart created using those settings.

.params
The structure of a pie widget entry is shown below for the General Purpose Dynamic Section.
The .params is passed to Contents.params.
For more details, refer to the Cortex XSIAM/XSOAR admin guide.
{
"Type": 17,
"ContentsFormat": "pie",
"Contents": {
"stats": ** snip **,
"params": <.params>
}
}
Tips: Sorting Fields by Value in .group = “fields”
When specifying fields for .group, each slice is plotted in the order defined in .fields.
This sample XQL query and the corresponding settings for the pie entry.
In this case, total_bytes_sent_mb will be displayed as the first slice, and total_bytes_received_mb will be displayed as the second slice.
dataset = panw_ngfw_traffic_raw
| dedup session_id
| comp sum(bytes_sent) as total_bytes_sent, sum(bytes_received) as total_bytes_received
"pie": {
"group": "fields",
"fields": {
"total_bytes_sent_mb": {},
"total_bytes_received_mb": {}
}
}
If you want to plot each field ordered by total bytes, you need to use .group = records instead.
To achieve this, modify the XQL query as shown below:
dataset = panw_ngfw_traffic_raw
| dedup session_id
| comp sum(bytes_sent) as total_bytes_sent, sum(bytes_received) as total_bytes_received
| alter x = arraycreate(
object_create("name", "Total Bytes Sent", "value", total_bytes_sent),
object_create("name", "Total Bytes Received", "value", total_bytes_received)
)
| arrayexpand x
| alter name = x -> name, value = x -> value
| fields name, value
This XQL query will return results similar to the following:
| name | value |
|---|---|
| Total Bytes Sent | 847200 |
| Total Bytes Received | 149358 |
To plot these fields ordered by total bytes, use .group = records with the following settings:
"pie": {
"group": "records",
"records": {
"name-field": "name",
"data-field": "value",
"sort": {
"by": "value",
"order": "desc"
}
}
}
Node: entry.line
| Path | Description | Type |
|---|---|---|
| .x.by | The name of the field by which values are aggregated into groups for the X-axis (e.g., time) of the line chart. | String |
| .x.sort-by | [Optional] The name of the field by which the X-axis values are sorted. If not specified, the .x.by field will be used by default. | String |
| .x.order | [Optional] The sort order of the values on the X-axis. Specifies either asc (default) for ascending or desc for descending. |
String |
| .x.field | [Optional] The name of the field that represents the value displayed for each X-axis item. | String |
| .y.group | Specifies records or fields. The details will be provided later in this section. |
String |
| .y.records | [Group-dependent parameters] This node is required only when records is set in .y.group. |
Dict |
| .y.fields | [Group-dependent parameters] This node is required only when fields is set in .y.group. |
Dict |
| .params | [Optional] The parameters provided to Contents.params in the widget entry. |
Dict |
y.group
When specifying records for .y.group, it indicates that the values are aggregated and displayed on the Y-axis, with the corresponding legend labels for each item in the series.
For example, given the following data:
| time | app | count |
|---|---|---|
| 00:00 | dns-base | 847200 |
| 00:00 | ssl | 149358 |
| 00:00 | ldap | 46000 |
| 01:00 | dns-base | 742800 |
| 01:00 | ssl | 150386 |
| 01:00 | ldap | 95686 |
| 02:00 | dns-base | 8469141 |
| 02:00 | ssl | 156459 |
| 02:00 | ldap | 138657 |
The count values are displayed on the Y-axis, with each corresponding app (e.g., dns-base, ssl, ldap) appearing as a legend label.
In this case, the settings must be configured under the .y.records node, as shown below:
| Path | Description | Type |
|---|---|---|
| .y.records.name-field | The name of the field used to display the legend labels for each item in the series. | String |
| .y.records.data-field | The name of the field that represents the summed value plotted for each X-axis item. | String |
| .y.records.colors | [Optional] The color configuration for each item in the series. For more details, see .colors. | List, Dict or String |
Below is a sample XQL query and the corresponding records structure in the entry node.
dataset = panw_ngfw_traffic_raw
| filter app != null
| bin _time span = 1h timeshift = 3600
| dedup session_id
| comp approx_top(app, 10) as apps by _time
| limit 240 // to allow retrieving up to 10 apps x 24 hours of records, as the default limit is 100.
| arrayexpand apps
| alter app = apps->value, count = to_integer(apps->count)
| alter time_eng = format_timestamp("%b %d %H %p", _time)
| alter time_ymd = format_timestamp("%Y-%m-%d %H %p", _time)
| fields app, count, time_eng, time_ymd
"entry": {
"type": "line",
"line": {
"x": {
"by": "_time",
"order": "asc",
"field": "time_eng"
},
"y": {
"group": "records",
"records": {
"name-field": "app",
"data-field": "count"
}
}
}
}
This is a sample line chart created using those settings.

When specifying fields for .y.group, it indicates that the values for each field are displayed individually on the Y-axis.
In this case, each field’s value is plotted as a separate series, with distinct legend labels for each field.
For example, given the following data:
| time | total_bytes_sent | total_bytes_received |
|---|---|---|
| 00:00 | 10234 | 50643 |
| 01:00 | 45011 | 68643 |
| 01:00 | 67633 | 345782 |
The total_bytes_sent and total_bytes_received fields are displayed as separate series on the Y-axis,
and each series will have its own legend label (e.g., total_bytes_sent and total_bytes_received).
In this case, the settings must be configured under the .y.fields node, as shown below:
| Path | Description | Type |
|---|---|---|
| .y.fields | A dictionary that defines the fields used for the line chart series, where each key represents a field name. | Dict |
| .y.fields.<field-name>.label | [Optional] The label to be displayed in the legend for the series. If not specified, the field name will be used as the label. | String |
| .y.fields.<field-name>.color | [Optional] [Optional] The color palette for the series. If not specified, the default color will be applied. | String |
Below is a sample XQL query and the corresponding fields structure in the entry node.
dataset = panw_ngfw_traffic_raw
| filter app != null
| bin _time span = 1h timeshift = 3600
| dedup session_id
| comp sum(bytes_sent) as total_bytes_sent, sum(bytes_received) as total_bytes_received by _time
| alter time_eng = format_timestamp("%b %d %H %p", _time)
| alter time_ymd = format_timestamp("%Y-%m-%d %H %p", _time)
| fields total_bytes_sent, total_bytes_received, time_eng, time_ymd
"entry": {
"type": "line",
"line": {
"x": {
"by": "_time",
"order": "asc",
"field": "time_eng"
},
"y": {
"group": "fields",
"fields": {
"total_bytes_sent": {
"label": "Total Bytes Sent",
"color": "rgb(122, 204, 0)"
},
"total_bytes_received": {
"label": "Total Bytes Received",
"color": "rgb(0, 122, 204)"
}
}
}
}
}
This is a sample line chart created using those settings.

.params
The structure of a line widget entry is shown below for the General Purpose Dynamic Section.
The .params is passed to Contents.params.
For more details, refer to the Cortex XSIAM/XSOAR admin guide.
{
"Type": 17,
"ContentsFormat": "line",
"Contents": {
"stats": ** snip **,
"params": <.params>
}
}
Node: entry.single-bar
| Path | Description | Type |
|---|---|---|
| .group | Specifies records or fields. The details will be provided later in this section. |
String |
| .records | [Group-dependent parameters] This node is required only when records is set in .group. |
Dict |
| .fields | [Group-dependent parameters] This node is required only when fields is set in .group. |
Dict |
| .params | [Optional] The parameters provided to Contents.params in the widget entry. |
Dict |
.group
When specifying records for .group, it indicates that each record represents a bar in the single-bar chart, with the corresponding legend label.
For example, given the following data:
| app | count |
|---|---|
| dns-base | 847200 |
| ssl | 149358 |
| netbios-dg | 145486 |
| ms-ds-smbv3 | 80879 |
| google-base | 240229 |
| incomplete | 2479222 |
| web-browsing | 122016 |
| ldap | 81384 |
| msrpc-base | 80914 |
| netbios-ns | 145722 |
The count values are displayed on the groups, with each corresponding app (e.g., dns-base, ssl, netbios-dg) shown as a legend label.
In this case, the settings must be configured under the .records node, as shown below:
| Path | Description | Type |
|---|---|---|
| .records.name-field | The name of the field used to display the legend labels for each bar in the single-bar chart. | String |
| .records.data-field | The name of the field that represents the summed value for each bar. | String |
| .records.colors | [Optional] The color configuration for each bar. For more details, see .colors. | List, Dict, or String |
| .records.sort.by | [Optional] The name of the field by which to sort the record set. If not specified, the value of .records.data-field will be used by default. |
String |
| .records.sort.order | [Optional] The sort order. Specifies either asc (default) for ascending or desc for descending. |
String |
Below is a sample XQL query and the corresponding records structure in the entry node.
dataset = panw_ngfw_traffic_raw
| filter app != null
| comp approx_top(app, 10) as apps
| arrayexpand apps
| alter app = apps->value, count = to_integer(apps->count)
| fields app, count
"single-bar": {
"group": "records",
"records": {
"name-field": "app",
"data-field": "count",
"sort": {
"by": "count",
"order": "desc"
}
},
"params": {
"layout": "horizontal"
}
}
This is a sample single-bar chart created using those settings.

When specifying fields for .group, it indicates that each field’s value is represented as a separate bar in the single-bar chart.
In this case, the values for each field are displayed individually, and each field is associated with its own distinct legend label.
For example, given the following data:
| total_bytes_sent | total_bytes_received |
|---|---|
| 1023405 | 5050643 |
The total_bytes_sent and total_bytes_received fields will each be displayed as separate bars in the single-bar chart.
Each slice will be labeled accordingly, with the legend reflecting the field names, such as total_bytes_sent and total_bytes_received.
In this case, the settings must be configured under the .fields node, as shown below:
| Path | Description | Type |
|---|---|---|
| .fields | A dictionary that defines the fields used for the single-bar chart groups, where each key represents a field name. | Dict |
| .fields.<field-name>.label | [Optional] The label to be displayed in the legend for the bars. If not specified, the field name will be used as the label. | String |
| .fields.<field-name>.color | [Optional] [Optional] The color palette for the bars. If not specified, the default color will be applied. | String |
Below is a sample XQL query and the corresponding fields structure in the entry node.
dataset = panw_ngfw_traffic_raw
| dedup session_id
| comp sum(bytes_sent) as total_bytes_sent, sum(bytes_received) as total_bytes_received
| fields total_bytes_sent, total_bytes_received
"single-bar": {
"group": "fields",
"fields": {
"total_bytes_sent_mb": {
"label": "Total Bytes Sent",
"color": "rgb(122, 204, 0)"
},
"total_bytes_received_mb": {
"label": "Total Bytes Received",
"color": "rgb(0, 122, 204)"
}
}
}
Each key defined in the .fields is plotted as a separate bar in the single-bar chart, in the order they are specified.
For example, in the above configuration, total_bytes_sent_mb will be displayed as the first bar, and total_bytes_received_mb will be displayed as the second bar.
This is a sample single-bar chart created using those settings.

.params
single-bar is a variation of the bar widget entry, and will create the structure shown below for the General Purpose Dynamic Section.
The .params is passed to Contents.params.
For more details, refer to the Cortex XSIAM/XSOAR admin guide.
{
"Type": 17,
"ContentsFormat": "bar",
"Contents": {
"stats": ** snip **,
"params": <.params>
}
}
Tips: Sorting Fields by Value in .group = “fields”
When specifying fields for .group, each bar is plotted in the order defined in .fields.
This sample XQL query and the corresponding settings for the single-bar entry.
In this case, total_bytes_sent_mb will be displayed as the first bar, and total_bytes_received_mb will be displayed as the second bar.
dataset = panw_ngfw_traffic_raw
| dedup session_id
| comp sum(bytes_sent) as total_bytes_sent, sum(bytes_received) as total_bytes_received
"single-bar": {
"group": "fields",
"fields": {
"total_bytes_sent_mb": {},
"total_bytes_received_mb": {}
}
}
If you want to plot each field ordered by total bytes, you need to use .group = records instead.
To achieve this, modify the XQL query as shown below:
dataset = panw_ngfw_traffic_raw
| dedup session_id
| comp sum(bytes_sent) as total_bytes_sent, sum(bytes_received) as total_bytes_received
| alter x = arraycreate(
object_create("name", "Total Bytes Sent", "value", total_bytes_sent),
object_create("name", "Total Bytes Received", "value", total_bytes_received)
)
| arrayexpand x
| alter name = x -> name, value = x -> value
| fields name, value
This XQL query will return results similar to the following:
| name | value |
|---|---|
| Total Bytes Sent | 847200 |
| Total Bytes Received | 149358 |
To plot these fields ordered by total bytes, use .group = records with the following settings:
"single-bar": {
"group": "records",
"records": {
"name-field": "name",
"data-field": "value",
"sort": {
"by": "value",
"order": "desc"
}
}
}
Node: entry.stacked-bar
| Path | Description | Type |
|---|---|---|
| .x.by | The name of the field by which values are aggregated into groups for the X-axis (e.g., time) of the stacked-bar chart. | String |
| .x.sort-by | [Optional] The name of the field by which the X-axis values are sorted. If not specified, the .x.by field will be used by default. | String |
| .x.order | [Optional] The sort order of the values on the X-axis. Specifies either asc (default) for ascending or desc for descending. |
String |
| .x.field | [Optional] The name of the field that represents the value displayed for each X-axis item. | String |
| .y.group | Specifies records or fields. The details will be provided later in this section. |
String |
| .y.records | [Group-dependent parameters] This node is required only when records is set in .y.group. |
Dict |
| .y.fields | [Group-dependent parameters] This node is required only when fields is set in .y.group. |
Dict |
| .params | [Optional] The parameters provided to Contents.params in the widget entry. |
Dict |
y.group
When specifying records for .y.group, it indicates that the values are aggregated and displayed on the Y-axis, with the corresponding legend labels for each item in the groups.
For example, given the following data:
| time | app | count |
|---|---|---|
| 00:00 | dns-base | 847200 |
| 00:00 | ssl | 149358 |
| 00:00 | ldap | 46000 |
| 01:00 | dns-base | 742800 |
| 01:00 | ssl | 150386 |
| 01:00 | ldap | 95686 |
| 02:00 | dns-base | 8469141 |
| 02:00 | ssl | 156459 |
| 02:00 | ldap | 138657 |
The count values are displayed on the Y-axis, with each corresponding app (e.g., dns-base, ssl, ldap) appearing as a legend label.
In this case, the settings must be configured under the .y.records node, as shown below:
| Path | Description | Type |
|---|---|---|
| .y.records.name-field | The name of the field used to display the legend labels for each item in the groups. | String |
| .y.records.data-field | The name of the field that represents the summed value plotted for each X-axis item. | String |
| .y.records.colors | [Optional] The color configuration for each item in the groups. For more details, see .colors. | List, Dict, or String |
Below is a sample XQL query and the corresponding records structure in the entry node.
dataset = panw_ngfw_traffic_raw
| filter app != null
| bin _time span = 1h timeshift = 3600
| dedup session_id
| comp approx_top(app, 10) as apps by _time
| limit 240 // to allow retrieving up to 10 apps x 24 hours of records, as the default limit is 100.
| arrayexpand apps
| alter app = apps->value, count = to_integer(apps->count)
| alter time_eng = format_timestamp("%b %d %H %p", _time)
| alter time_ymd = format_timestamp("%Y-%m-%d %H %p", _time)
| fields app, count, time_eng, time_ymd
"entry": {
"type": "stacked-bar",
"stacked-bar": {
"x": {
"by": "_time",
"order": "asc",
"field": "time_eng"
},
"y": {
"group": "records",
"records": {
"name-field": "app",
"data-field": "count"
}
},
"params": {
"layout": "horizontal"
}
}
}
This is a sample stacked-bar chart created using those settings.

When specifying fields for .y.group, it indicates that the values for each field are displayed individually on the Y-axis.
In this case, each field’s value is plotted as a separate group, with distinct legend labels for each field.
For example, given the following data:
| time | total_bytes_sent | total_bytes_received |
|---|---|---|
| 00:00 | 10234 | 50643 |
| 01:00 | 45011 | 68643 |
| 01:00 | 67633 | 345782 |
The total_bytes_sent and total_bytes_received fields are displayed as separate groups on the Y-axis,
and each groups will have its own legend label (e.g., total_bytes_sent and total_bytes_received).
In this case, the settings must be configured under the .y.fields node, as shown below:
| Path | Description | Type |
|---|---|---|
| .y.fields | A dictionary that defines the fields used for the line chart groups, where each key represents a field name. | Dict |
| .y.fields.<field-name>.label | [Optional] The label to be displayed in the legend for the groups. If not specified, the field name will be used as the label. | String |
| .y.fields.<field-name>.color | [Optional] [Optional] The color palette for the groups. If not specified, the default color will be applied. | String |
Below is a sample XQL query and the corresponding fields structure in the entry node.
dataset = panw_ngfw_traffic_raw
| filter app != null
| bin _time span = 1h timeshift = 3600
| dedup session_id
| comp sum(bytes_sent) as total_bytes_sent, sum(bytes_received) as total_bytes_received by _time
| alter time_eng = format_timestamp("%b %d %H %p", _time)
| alter time_ymd = format_timestamp("%Y-%m-%d %H %p", _time)
| fields total_bytes_sent, total_bytes_received, time_eng, time_ymd
"entry": {
"type": "stacked-bar",
"stacked-bar": {
"x": {
"by": "_time",
"order": "asc",
"field": "time_eng"
},
"y": {
"group": "fields",
"fields": {
"total_bytes_sent": {
"label": "Total Bytes Sent",
"color": "rgb(122, 204, 0)"
},
"total_bytes_received": {
"label": "Total Bytes Received",
"color": "rgb(0, 122, 204)"
}
}
},
"params": {
"layout": "vertical"
}
}
}
This is a sample stacked-bar chart created using those settings.

.params
stacked-bar is a variation of the bar widget entry, and will create the structure shown below for the General Purpose Dynamic Section.
The .params is passed to Contents.params.
For more details, refer to the Cortex XSIAM/XSOAR admin guide.
{
"Type": 17,
"ContentsFormat": "bar",
"Contents": {
"stats": ** snip **,
"params": <.params>
}
}
Node: entry.duration
| Path | Description | Type |
|---|---|---|
| .field | The name of the field whose value specifies the duration, and the value must be in seconds. | String |
| .params | [Optional] The parameters provided to Contents.params in the widget entry. |
Dict |
The structure of a duration widget entry is shown below for the General Purpose Dynamic Section.
The .params is passed to Contents.params.
For more details, refer to the Cortex XSIAM/XSOAR admin guide.
{
"Type": 17,
"ContentsFormat": "duration",
"Contents": {
"stats": 86340,
"params": <.params>
}
}
A sample structure of duration in an entry node is shown below.
"entry": {
"type": "duration",
"duration": {
"field": "_duration"
}
}
That is intended to create a number widget from the record set, as shown below, retrieved by the XQL query.
| _duration |
|---|
| 86340 |
The widget will be displayed as shown below.

The record set must contain at most one record for the duration widget entry. An error is raised if it contains more than one record.
Node: entry.default
| Path | Description | Type |
|---|---|---|
| .scope | [Optional] A list of scope. The possible values are no_recordset and query_skipped, which indicate when no record set is available or when a query is skipped, respectively (Default = [“no_recordset”, “query_skipped”]). |
String or List |
| .entry | [Optional] The default entry that is returned when the conditions specified in .scope are met, and no specific default entries are available. |
String or Dict |
| .no_recordset | [Optional] The default entry returned when no_recordset is given to .scope, and the specified conditions are met. |
String or Dict |
| .query_skipped | [Optional] The default entry returned when query_skipped is given to .scope, and the specified conditions are met. |
String or Dict |
The default entry is a fallback value. Instead of creating an entry from the query results,
it is used to display a message indicating that the query was not executed or that no record set is available.
It will be applied if either of the following conditions is met:
query_skippedis included in the.scope, and the.query.conditionsin thequerynode evaluates to false.no_recordsetis included in the.scope, and no record set is returned by the XQL query.
The .no_recordset, .query_skipped and .entry must be of type str or dict.
If it is of type str, it represents markdown text to be displayed as an entry, as shown below:
{
"Type": 1,
"ContentsFormat": "markdown",
"HumanReadable": <markdown text>
"Contents": null
}
If it is of type dict, the value is returned as-is.
Common Node: .colors
The colors node configures color palettes for each slice, series, or group in the graph chart.
The value can be of type str, dict, or list.
.colors (str) - specified color
When the value is of type str, it specifies the color applied to all items (slice, series, or group).
"colors": "rgb(0, 144, 255)"
.colors (dict) - color mapping
When the value is of type dict, it represents the color mapping.
Each key in the dictionary corresponds to a specific category or group (a slice, series, or group) in the chart,
and the associated value is the color that will be applied to that category.
For example, in the provided configuration:
"colors": {
"dns-base": "rgb(0, 144, 255)",
"incomplete": "rgb(255, 144, 0)",
"ssl": "rgb(255, 0, 144)"
}
The category dns-base will be colored rgb(0, 144, 255), incomplete will be colored rgb(255, 144, 0), and ssl will be colored rgb(255, 0, 144).
If a category is not specified in the mapping, it will use the default color scheme.
.colors (list) - color order
When the value is of type list, it represents an array of colors that will be applied sequentially to each item (slice, series, or group) in the chart.
The colors in the list are applied in the order they appear, starting with the first item in the chart.
If the number of items exceeds the number of colors in the list, the default color scheme will be applied.
For example, in the provided configuration:
"colors": [
"rgb(0, 144, 255)",
"rgb(20, 144, 255)",
"rgb(40, 144, 255)",
"rgb(60, 144, 255)",
"rgb(80, 144, 255)",
"rgb(100, 144, 255)",
"rgb(120, 144, 255)",
"rgb(140, 144, 255)",
"rgb(160, 144, 255)",
"rgb(180, 144, 255)",
"rgb(200, 144, 255)"
]
The colors will be applied to the chart items in the following order: rgb(0, 144, 255) for the first item, rgb(20, 144, 255) for the second item, and so on.
If there are more than 11 items in the chart, the default color scheme will be applied for the remaining items.
Variable Substitution
Variable Substitution is a syntax used to dynamically replace variables within a string. It enables the insertion of values from Lists, Context Data, Alert Context (Cortex XSIAM), Incident Context (Cortex XSOAR) or Extended Variables, into predefined templates.
By default, a variable is enclosed by ${ and }, and those symbols can be changed using the .config.variable_substitution in the template or the variable_substitution argument parameter.
e.g.
| filter action_remote_ip = "${issue.remoteip}"
The syntax supports the DT expression, allowing you to make template text more flexible and customizable.
e.g.
| filter action_remote_ip = "${issue.remoteip=>val ? val[0] : "255.255.255.255"}"
Variables can be replaced by the standard Cortex XSIAM/XSOAR DT expression.
- ${<context-path>}
- ${lists.<list-name>}
- ${alert.<alert-field>}
- ${issue.<issue-field>}
- ${incident.<incident-field>}
In addition, it supports extended variables that start with ..
- ${.recordset}
- It refers to the record set retrieved by the XQL query.
- ${.query.string}
- It refers to the query string used in the XQL query.
- ${.query.timeframe.from}
- It refers to the start time of the time frame in ISO 8601 time format in UTC applied in the XQL query.
- ${.query.timeframe.to}
- It refers to the end time of the time frame in ISO 8601 time format in UTC applied in the XQL query
- ${.query.execution_id}
- It refers to the unique execution ID for the request query.
- ${.query.request_url}
- It refers to the URL path, including query parameters, used to search datasets in the XQL builder. (e.g., /xql/xql-search?phrase=dataset%3Dxdr_data&timeframe=%7B%22from%22%3A%201734190414000%2C%20%22to%22%3A%201734276814000%7D)
- ${.query.result_url}
- It refers to the URL path used to get the results of an executed query in the XQL builder. (e.g., /xql/xql-search/1234567890abcd_123456_inv)
Caching
When caching is enabled, data is stored in the context data and retrieved from it to create the entry, preventing repeated execution of the same query with the same conditions in order to improve performance.
It is managed under the XQLDSHelperCache.<template-name> path within the context data.
Caching can be controlled by the cache_type argument parameter.
- cache_type:
recordset- All the record sets retrieved from the query are stored in the cache. Entries will be correctly created even when only the parameters in the “entry” are modified, as they are rebuilt from the raw record sets in the cache, provided the query parameters remain unchanged.
- cache_type:
entry- Only entry data is stored in the cache. The same entry is returned if the query parameters remain unchanged. This option helps minimize the cache data size. However, if only the parameters within the entry are modified (for example, if the ‘colors’ parameter is modified), the correct entry cannot be retrieved, as it does not re-query the record set to create a new entry with the updated settings.
Record Limit for Query Results
XQL queries are executed using the xdr-xql-generic-query command, which is provided by the XQL Query Engine integration.
By default, the command returns up to 100 records. To retrieve more records, you need to add a limit stage to the XQL query.
Below is a sample XQL query that includes a limit stage:
dataset = panw_ngfw_traffic_raw
| filter app != null
| bin _time span = 1h timeshift = 3600
| dedup session_id
| comp approx_top(app, 10) as apps by _time
| limit 240 // to allow retrieving up to 10 apps x 24 hours of records, as the default limit is 100.
Locking Queries
In Cortex XSIAM and Cortex XDR, the number of concurrent XQL query executions via the REST API is limited to four.
Submitting more queries than this may lead to errors.
The script XQLDSHelper supports retry logic and can wait until execution becomes possible, but by holding onto an execution slot it might impact other playbooks running queries in parallel.
To prevent this, exclusive locking is supported via the Core Lock or Demisto Lock mechanism.
When .query.locking is enabled, XQLDSHelper acquires a lock during query execution and releases it afterward.
This ensures that multiple queries do not run concurrently and allows coordinated execution alongside other playbooks.
Backward Compatibility for Cortex XSIAM 2.x
Due to naming policy updates, alert fields in Cortex XSIAM 2.x have been changed to issue fields in the Cortex Unified Platform.
Correspondingly, the namespace has also been updated from alert to issue.
For backward compatibility, the XQLDSHelper still allows you to reference issue fields using the alert namespace, as well as the new issue namespace.
Sample Content Bundle
To assist you more effectively, we’ve provided sample automation scripts with List data, including template data and XQL query strings, in a content bundle.
You can conveniently download them using the link below.
Once you’ve imported the bundle into your Cortex XSIAM or XSOAR instance, you will see lists starting with XQLDS_Sample_ in the Lists menu for template data and XQL queries, and automation scripts starting with XQLDS_Sample_ in the Scripts menu for displaying widgets in the General Purpose Dynamic Section.
XQLDS_Sample_Templates in the Lists is the main template used by the automation scripts. You can refer to it to deepen your understanding of the template structure.


