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 Lists of Cortex XSIAM/XSOAR. The list of templates is retrieved from it.

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 OR condition, where each element in the list is treated as a separate condition.
    For example, the list [X, Y, Z] is evaluated as X 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 AND and OR conditions.
    Each key-value pair in the dictionary is treated as an AND condition.
    The dictionary as a whole is evaluated as a combination of these AND conditions, connected by OR.
    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.

Number

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.

Number Trend

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.

Dynamic-Pie

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.

Static-Pie

.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.

Dynamic-Line

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.

Static-Line

.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.

Dynamic-Single-Bar

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.

Static-Single-Bar

.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.

Dynamic-Stacked-Bar

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.

Static-Stacked-Bar

.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.

Duration

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_skipped is included in the .scope, and the .query.conditions in the query node evaluates to false.
  • no_recordset is 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.