Cortex XSOAR is a security oriented automation platform, and one of the areas where it stands out is customization.
A recurring problem in a SOC is data visualization, analysts can be swarmed with information, and finding out what piece of data is currently both relevant and significant can become hard. One of our tasks as SOAR engineers is to ease the decision process for analysts, we do so by providing additional contextual information about the incidents they handle, directly within the incident layout. In this objective, we incorporate number widgets into the analyst interface, these allow us to tell more visual stories about the security incidents we manage in XSOAR. From raw and sometimes unorganized data, they let us bring up eye-catching depictions of elements that can help in assessing the impact and veracity of a detection.
In this blogpost, we will focus on the use of number widgets.
We will show you how to make use of them for outputting information to the war room, incidents, indicators and dasbhoards. On top of that we will also cover how to add trends information and even how to integrate them into a dashboard with a dynamic query. In the previous post in the series, we looked at dynamic sections in Cortex XSOAR and how to leverage them to display text in a tree like way. If you are not familiar with Cortex XSOAR and dynamic sections, please read the previous post in the series.
We previously saw that we could use dynamic dections to display text, but there are a few other options available to us. These options are broken down here. In this post, we will:
Let’s begin with a new automation and follow the instructions available in the number widget example of the PaloAlto documentation. When we run their example, we get the following result:
As expected, the example works out of the box. Let’s now go and make the widget display data from Microsoft Sentinel.
To display data pulled from Microsoft Sentinel (Microsoft Azure’s cloud native SIEM), we first need to call an integration command. Here we use an instance of the Azure Log Analytics integration available in the Cortex XSOAR marketplace:
res = demisto.executeCommand( "azure-log-analytics-execute-query", { "query": THE_QUERY } )
We need a query to run, we will develop it on Sentinel before using it from Cortex XSOAR.
We will be looking at entries in SecurityIncident
, a table that holds information about the security incidents present in your Sentinel deployment. We will query that table, and count the number of distinct incidents in a given month. The query we will use for that is the following:
SecurityIncident | where TimeGenerated between ( datetime("2022-10-01T00:00:00+00:00") .. datetime("2022-11-01T00:00:00+00:00")) | summarize count()
Now that we know our query works, we will port it to Cortex XSOAR. We start by duplicating our previous automation and adding code to call the integration with the Sentinel query.
res = demisto.executeCommand("azure-log-analytics-execute-query", { "query": """SecurityIncident | where TimeGenerated between( datetime("2022-10-01T00:00:00+00:00") .. datetime("2022-11-01T00:00:00+00:00")) | summarize count()""" })
We need to extract the count_
we could observe in the results of Sentinel, let’s inspect the res
object returned to us by the integration.
Upon inspection of the returned object, we identify that we can use the following logic to extract the count of incidents
counts = [] for result in results: if not ( isinstance(result, dict) and isinstance(contents := result.get("Contents"), list) ): continue for content in contents: if ( isinstance(content, dict) and isinstance(count := content.get("count_"), int) ): counts.append(count) total_count = sum(counts)
With the total_count
obtained, we can simply change the hardcoded number from our previous widget and replace it with the value we just fetched:
demisto.results( { "Type": 17, "ContentsFormat": "number", "Contents": { "stats": total_count, "params": { "name": "Incidents Last Month", "colors": { "items": { "green": { "value": 40 } } } } } } )
In the snippet above we use demisto.results()
, this function let’s us write to the standard output that will be read by Cortex XSOAR. More possibilities for returning data from an automation are available in this documentation page: Python code conventions, returning data. Here we use the type 17
in the data we return, this is the type associated to widgets, the list of all defined types is available here.
Upon running our new automation, we get the exact same number previously obtained through Sentinel:
We already have the number of alerts from last month pulled into XSOAR and displayed as a widget, let’s continue and also pull the count for the previous month. Our query to Sentinel now becomes:
SecurityIncident | where TimeGenerated between ( datetime("2022-10-01T00:00:00+00:00").. datetime("2022-11-01T00:00:00+00:00")) | extend same = 1 | union ( SecurityIncident | where TimeGenerated between ( datetime("2022-09-01T00:00:00+00:00").. datetime("2022-10-01T00:00:00+00:00")) | extend same = 2) | summarize count() by same
Correspondingly, our querying and extracting code becomes:
this_month_counts = list() last_month_counts = list() lookup = { 1: this_month_counts, 2: last_month_counts } for result in results: if not ( isinstance(result, dict) and isinstance(contents := result.get("Contents"), list) ): continue for content in contents: if not isinstance(content, dict): continue if not isinstance(raw_same_target := content.get("same"), int): continue same_target = lookup.get(raw_same_target) if ( same_target is not None and isinstance(count := content.get("count_"), int) ): same_target.append(count) total_this_month_counts = sum(this_month_counts) total_last_month_counts = sum(last_month_counts)
As for the data returned to Cortex XSOAR, the only change is on the stats
key which now becomes:
"stats": { "prevSum": total_last_month_counts, "currSum": total_this_month_counts }
The resulting widget looks as follows:
Until now, we have been displaying our widgets in the war room, however we can also add them to incident and indicator layouts as well. As a reminder, the procedure to add General Purpose Dynamic sections to an incident can be found here: Add a Script to the incident Layout.
Our existing widgets are already compatible with incidents and indicators, after following the instructions above on how to add widgets to incidents, we can get the following layout tab. In a similar fashion after adding the dynamic-indicator-section
tag to all three automations, you can also add them as widgets to an indicator layout:
Rendering widgets in a dashboard is actually easier than in an incident layout, to verify this, let’s compare the methods to output a simple number widget, both for an incident and for a dashboard. For an incident, as we already saw earlier, you need to return the actual number, but it needs to be wrapped appropriately:
data = { "Type": 17, "ContentsFormat": "number", "Contents": { "stats": 53, "params": { "layout": "horizontal", "name": "Lala", "sign": "@", "colors": { "items": { "#00CD33": { "value": 10 }, "#FAC100": { "value": 20 }, "green": { "value": 40 } } }, "type": "above" } } } demisto.results(data)
In contrast, it is much easier for a dashboard:
result = 10 demisto.results(result)
The difference here is that when building a dashboard, you can access the widget builder:
Whereas from an incident, you need to explicitly return metadata defining the look and feel of your widget.
Therefore, if we want to make it possible for our automations to be used from a dashboard too, we need to adapt them to return either a simple value if being called from a dashboard, or a wrapped value if called from an incident or indicator.
Our first addition to the existing scripts will be to identify whether we’re being called from a dashboard, we will use the following snippet for this purpose.
is_dashboard = demisto.args().get("widgetType") is not None
This works because dashboards that have automation based widgets add a special argument when calling these automations. This special argument mentions the expected results type and can be found under the key widgetType
, it’s presence is a good indication that your automation has been called from a dashboard.
We can now differentiate our outputted results depending on whether or not we are in a dashboard. For that, we separate our incident/indicator results in two, between the actual data and the wrapper. This snippet exposes the statement above applied to our first automation:
number = 53 data = { "Type": 17, "ContentsFormat": "number", "Contents": { "stats": number, ......
if is_dashboard: demisto.results(number) else: demisto.results(data)
We do this with our three automations and also add the widget
tag to them to make them selectable as source for automation-based dashboard widgets. Once added to a dashboard, our widgets look as follows:
At this point we are powering our widgets with data from Sentinel, but we are always looking at data from the same timeframe. Because dashboards have a time picker, we can instead start to use that data to determine the timeframe we are querying Sentinel for. Extraction of timeframe data from dashboards was covered in this previous blogpost.
We start by adding this line to our automation:
FromDate, ToDate = ( NitroDateFactory .from_regular_xsoar_date_range_args( demisto.args() ) )
This gives us two NitroDates we can use to craft our Sentinel queries. In our second script which queries a single timeframe, the code becomes:
from_ = "2022-10-01T00:00:00+00:00" to_ = "2022-11-01T00:00:00+00:00" if is_dashboard: if isinstance(FromDate, NitroRegularDate): from_ = FromDate.to_iso8601() else: from_ = None if isinstance(ToDate, NitroRegularDate): to_ = ToDate.to_iso8601() else: to_ = None query = "SecurityIncident" tmp_query_list = list() if from_ is not None: tmp_query_list.append(f'TimeGenerated >= datetime("{from_}")') if to_ is not None: tmp_query_list.append(f'TimeGenerated >= datetime("{to_}")') if tmp_query_list: query += "\n| where " + " and ".join(tmp_query_list) query += """ | extend same = 1 | summarize count() by same"""
The logic we are modifying is the one describing how we craft our Kusto query (the query langage used in Microsoft Sentinel). We previously always had at our disposal a from_
and a to_
string representing the beginning and end of the timeframe we were interested in. With the selection dashboard date range selector, this is not the case anymore, we may get only a start date if the selector is on “3 days ago to now”, or only an end date if the selector is on “up to 3 days ago”. We must then change the logic we use to craft our query in a way that reflects this change. To accomodate this, we replace the between
statement with >=
and <=
statements used to compare the TimeGenerated
of an incident to the dates transmitted by the dashboard.
In a similar fashion, we modify the 3rd automation to calculate both the initial timeframe, and the previous timeframe from the dates passed down by the dashboard.
from_ = "2022-10-01T00:00:00+00:00" to_ = "2022-11-01T00:00:00+00:00" from_2 = "2022-09-01T00:00:00+00:00" to_2 = "2022-10-01T00:00:00+00:00" if is_dashboard: if isinstance(FromDate, NitroRegularDate): if isinstance(ToDate, NitroRegularDate): td = ToDate.date else: td = datetime.now(timezone.utc) delta = td - FromDate.date from2 = NitroRegularDate(date=FromDate.date - delta) to2 = FromDate else: from2 = FromDate to2 = ToDate if isinstance(FromDate, NitroRegularDate): from_ = FromDate.to_iso8601() else: from_ = None if isinstance(ToDate, NitroRegularDate): to_ = ToDate.to_iso8601() else: to_ = None if isinstance(from2, NitroRegularDate): from_2 = from2.to_iso8601() else: from_2 = None if isinstance(to2, NitroRegularDate): to_2 = to2.to_iso8601() else: to_2 = None query = "SecurityIncident" tmp_query_list = list() if from_ is not None: tmp_query_list.append(f"TimeGenerated >= datetime(\"{from_}\")") if to_ is not None: tmp_query_list.append(f"TimeGenerated < datetime(\"{to_}\")") if tmp_query_list: query += "\n| where " + " and ".join(tmp_query_list) query += """ | extend same = 1 | union ( SecurityIncident""" tmp_query_list2 = list() if from_2 is not None: tmp_query_list2.append(f"TimeGenerated >= datetime(\"{from_2}\")") if to_2 is not None: tmp_query_list2.append(f"TimeGenerated < datetime(\"{to_2}\")") if tmp_query_list2: query += "\n| where " + " and ".join(tmp_query_list2) query += """ | extend same = 2) | summarize count() by same"""
Our dashboard is now fully dynamic, with two widgets presenting data corresponding to the selected timeframe:
We have covered the use of number widgets throughout Cortex XSOAR in pretty much every scenario, and have managed to make use of all the inputs available to us. Although the process used in this post was centered around number widgets, it should be noted that it can be applied to all other types of widgets.
Cortex XSOAR documentation: script based widget examples
Cortex XSOAR documentation: script based widget example 2
Cortex XSOAR marketplace: Azure Log Analytics Integration
Cortex XSOAR documentation: Python code conventions
GIthub: Cortex XSOAR source – EntryTypes
Cortex XSOAR documentation: adding a script to an incident layout
Benjamin Danjoux
Benjamin is a senior engineer in NVISO’s SOAR engineering team.
As the SOAR engineering design lead, he is responsible for the overall architecture and organization of the automated workflows running on Palo Alto Cortex XSOAR, which enables the NVISO SOC analysts to detect attackers in customer environments.