Notebook Version: 1.0
Python Version: Python 3.6
Data Sources Required: MDATP SecurityAlert, W3CIIS Log (or similar web logging)
This notebook investigates Microsoft Defender Advanced Threat Protection (MDATP) webshell alerts. The notebook will guide you through steps to collect MDATP alerts for webshell activity and link them to server access logs to identify potential attackers.
Configuration Required!
This Notebook presumes you have Azure Sentinel Workspace settings configured in a config file. If you do not have this in place please read the docs and use this notebook to test.
This notebook provides a step-by-step investigation to understand MDATP webshell alerts on your server. While our example uses IIS logging this notebook can be converted to support any web log type.
After congiuration you can investigate two scenarios, a webshell file alert or a webshell command execution alert. For each of these we will need to retrieve different data, the notebook contains branching execution at Step 3 to enable this.
Below you'll find a more detailed description of the two types of investigation
This alert type will fire when a file that is suspected to be a webshell appears on disk. For this investigation we will start with a known filename that is a suspected shell (e.g. Setconfigure.aspx) and we will try to understand how this webshell was placed on the server.
This alert type will fire when a command is executed on your web server that is suspicious. For this investigation we start with the command line that was executed and the time window that execution took place.
For both of the above alert types this notebook will allow you to find the following information:
Once we have that information this notebook will allow you to investigate the attacker IP, User Agent or both to discover:
This cell:
from pathlib import Path
import os
import sys
import warnings
from ipywidgets import HBox
from IPython.display import display, HTML, Markdown
REQ_PYTHON_VER=(3, 6)
REQ_MSTICPY_VER=(0, 5, 0)
display(HTML("<h3>Starting Notebook setup...</h3>"))
if Path("./utils/nb_check.py").is_file():
from utils.nb_check import check_python_ver, check_mp_ver
check_python_ver(min_py_ver=REQ_PYTHON_VER)
try:
check_mp_ver(min_msticpy_ver=REQ_MSTICPY_VER)
except ImportError:
!pip install --user --upgrade msticpy
if "msticpy" in sys.modules:
importlib.reload(msticpy)
else:
import msticpy
check_mp_ver(MSTICPY_REQ_VERSION)
from msticpy.nbtools import nbinit
nbinit.init_notebook(
namespace=globals(),
extra_imports=["ipwhois, IPWhois"]
);
# This time period is used to determine how far back the analytic looks, e.g. 1h, 3d, 7d, 1w
# If you expereince timeout errors or the notebook is returning too much data, try lowering this
time_range = pick_time_range.value
workspace_name = target_workspace.value
# Collect Azure Sentinel Workspace Details from our config file and use them to connect
try:
# Update to WorkspaceConfig(workspace="WORKSPACE_NAME") to get alerts from a Workspace other than your default one.
# Run WorkspaceConfig().list_workspaces() to see a list of configured workspaces
ws_config = WorkspaceConfig(workspace=workspace_name)
ws_id = ws_config['workspace_id']
ten_id = ws_config['tenant_id']
md("Workspace details collected from config file")
qry_prov = QueryProvider(data_environment='LogAnalytics')
qry_prov.connect(connection_str=ws_config.code_connect_str)
except RuntimeError:
md("""You do not have any Workspaces configured in your config files.
Please run the https://github.com/Azure/Azure-Sentinel-Notebooks/blob/master/ConfiguringNotebookEnvironment.ipynb
to setup these files before proceeding""" ,'bold')
# This cell will collect an alert summary to help you decide which investigation to launch
alert_summary_query = f'''
let timeRange = {time_range};
SecurityAlert
| where ProviderName =~ "MDATP"
| where DisplayName has_any("Possible IIS web shell", "Possible IIS compromise", "Suspicious processes indicative of a web shell", "A suspicious web script was created", "Possible web shell installation")
| extend AlertType = iff(DisplayName has_any("Possible IIS web shell", "Possible IIS compromise", "Suspicious processes indicative of a web shell", "A suspicious web script was created"), "Webshell Command Alerts", "Webshell File Alerts")
| summarize count(AlertType) by AlertType
| project AlertType, NumberOfAlerts=count_AlertType
'''
display(HTML('<h2>Alert Summary</h2><p>The following alert types have been found on your server:'))
alertout = qry_prov.exec_query(alert_summary_query)
Now it's time to select which type of investigation you would like to try. Above we have provided a summary of the high-level alert types present on your server, if the above table is blank no alerts were found.
If you have alerts you have a couple of different options.
You can click the links to jump to the start of the investigation.
Shell file alert Investigation: If you would like to conduct an investigation into an ASPX file that has been detected by Microsoft Defender ATP please run the code block beneath "Begin File Investigation"
Shell command alert Investigation: If you would like to conduct an investigation into suspicious command execution on your web server please run the code block below "Begin Command Investigation"
We can now begin our investigation into a webshell file that has been placed on a system in your network. We'll start by collecting relevant events from MDATP.
# First the notebook collects alerts from MDATP with the following query
display(HTML('<h3>Collecting relevant alerts from MDATP</h3>'))
mdatp_events_query = f'''
let timeRange = {time_range};
let scriptExtensions = dynamic([".php", ".jsp", ".js", ".aspx", ".asmx", ".asax", ".cfm", ".shtml"]);
SecurityAlert
| where TimeGenerated > ago(timeRange)
| where ProviderName == "MDATP"
| where DisplayName =~ "Possible web shell installation"
| extend alertData = parse_json(Entities)
| mvexpand alertData
| where alertData.Type == "file"
| where alertData.Name has_any(scriptExtensions)
| extend filename = alertData.Name, directory = alertData.Directory
| project TimeGenerated, filename, directory
'''
aspx_data = qry_prov.exec_query(mdatp_events_query)
shells = aspx_data['filename']
# Everything below is presentational
pick_shell = widgets.Dropdown(
options=shells,
decription="Webshells",
disabled=False,
)
if isinstance(aspx_data, pd.DataFrame) and not aspx_data.empty:
display(HTML('<p>Below you can see the filename, the directory it was found in, and the time it was found.</p><p>Please select a webshell to investigate before you continue:</p>'))
display(aspx_data)
display(pick_shell)
display(HTML('<hr>'))
display(HTML('<h1>Collect Enrichment Events</h1>'))
display(HTML('<p>Now we will enrich this webshell event with additional information before continuing to find the attacker.</p>'))
else:
md_warn('No relevant alerts were found in your MDATP logs, try expanding your timeframe in the config.')
# Now collect enrichments from the W3CIIS log table
dfindex = pick_shell.index
filename = aspx_data.loc[[dfindex]]['filename'].values[0]
directory = aspx_data.loc[[dfindex]]['directory'].values[0]
timegenerated = aspx_data.loc[[dfindex]]['TimeGenerated'].values[0]
# Check the directory matches
directory_split = directory.split("\\")
first_directory = directory_split[-1]
# This query will collect file accessed on the server within the same time window
iis_query = f'''
let scriptExtensions = dynamic([".php", ".jsp", ".js", ".aspx", ".asmx", ".asax", ".cfm", ".shtml"]);
W3CIISLog
| where TimeGenerated >= datetime("{timegenerated}") - 10s
| where TimeGenerated <= datetime("{timegenerated}") + 10s
| where csUriStem has_any(scriptExtensions)
| extend splitUriStem = split(csUriStem, "/")
| extend FileName = splitUriStem[-1] | extend firstDir = splitUriStem[-2]
| where FileName == "{filename}" and firstDir == "{first_directory}"
| summarize StartTime=min(TimeGenerated), EndTime=max(TimeGenerated) by AttackerIP=cIP, AttackerUserAgent=csUserAgent, SiteName=sSiteName, ShellLocation=csUriStem
| order by StartTime asc
'''
if isinstance(iis_data, pd.DataFrame) and not iis_data.empty:
iis_data = qry_prov.exec_query(iis_query)
display(HTML('<div style="border-left: 6px solid #ccc; border-left-width: 6px; border-left-style: solid; padding: 0.01em 16px; border-color:#00cc69; background-color:#e6fff3;"><h3>Enrichment complete!<br> Please <a href="#Step-1:-Find-the-Attacker">click here</a> to continue your investigation.</h3><br></div><hr>'))
else:
if iis_data.empty:
md_warn('No events were found in W3CIISLog')
else:
md_warn('The query failed, it may have timed out')
To begin the investigation into a command that has been executed by a webshell on your network, we will begin by collecting MDATP data.
command_investigation_query = f'''
let timeRange = {time_range};
let alerts = SecurityAlert
| where TimeGenerated > ago(timeRange)
| extend alertData = parse_json(Entities), recordGuid = new_guid();
let shellAlerts = alerts
| where ProviderName =~ "MDATP"
| mvexpand alertData
| where alertData.Type == "file" and alertData.Name == "w3wp.exe"
| distinct SystemAlertId
| join kind=inner (alerts) on SystemAlertId;
let alldata = shellAlerts
| mvexpand alertData
| extend Type = alertData.Type;
let filedata = alldata
| extend id = tostring(alertData.$id)
| extend ImageName = alertData.Name
| where Type == "file" and ImageName != "w3wp.exe"
| extend imagefileref = id;
let commanddata = alldata
| extend CommandLine = tostring(alertData.CommandLine)
| extend creationtime = tostring(alertData.CreationTimeUtc)
| where Type =~ "process"
| where isnotempty(CommandLine)
| extend imagefileref = tostring(alertData.ImageFile.$ref);
let hostdata = alldata
| where Type =~ "host"
| project HostName = tostring(alertData.HostName), DnsDomain = tostring(alertData.DnsDomain), SystemAlertId
| distinct HostName, DnsDomain, SystemAlertId;
filedata
| join kind=inner (
commanddata
) on imagefileref
| join kind=inner (hostdata) on SystemAlertId
| project DisplayName, recordGuid, TimeGenerated, ImageName, CommandLine, HostName, DnsDomain
'''
cmd_data = qry_prov.exec_query(command_investigation_query)
if isinstance(cmd_data, pd.DataFrame) and not cmd_data.empty:
display(HTML('''<h2>Step 3.1: Select a command to investigate</h2>
<p>Below you will find the suspicious commands that were executed. Matching GUIDs indicate that the events were linked and likely executed within seconds of each other,
for the purpose of the investigation you can select either as the default time windows are wide enough to encapsulate both events. There's a full breakdown of the fields below.</p>
<ul>
<li>DisplayName: The MDATP alert display name</li>
<li>recordGuid: A GUID used to track previoulsy linked events</li>
<li>TimeGenerated: The time the log entry was made</li>
<li>ImageName: The executing process image name</li>
<li>CommandLine: The command line that was executed</li>
<li>HostName: The host name of the impacted machine</li>
<li>DnsDomain: The domain of the impacted machine</li>
</ul>
<p>Note: The GUID generated here will change with each execution and is used only by the notebook.</p>'''))
command = cmd_data['recordGuid']
pick_cmd = widgets.Dropdown(
options=command,
decription="Commands",
disabled=False,
)
display(HTML('<h3>Select the GUID associated with the command you wish to investigate.</h3>'))
display(pick_cmd)
display(HTML('<hr><h2>Step 3.2: Execute to Collect Events</h2><p>Please select an access threshold, by default the script will look for files on the server that have been accessed by fewer than 3 IP addresses</p>'))
access_threshold = widgets.IntSlider(
value=3,
min=0,
max=15,
step=1,
decription="Access Threshold",
disabled=False,
orientation='horizontal',
readout=True,
readout_format='d'
)
display(access_threshold)
else:
if iis_data.empty:
md_warn('No events were found in SecurityAlert. Continuing will result in errors.')
else:
md_warn('The query failed, it may have timed out. Continuing will result in errors.')
dfindex = pick_cmd.index
imagename = cmd_data.loc[[dfindex]]['ImageName'].values[0]
commandline = cmd_data.loc[[dfindex]]['CommandLine'].values[0]
creationtime = cmd_data.loc[[dfindex]]['TimeGenerated'].values[0]
# Retrieves access to script files on the web server using logs stored in W3CIIS.
# Checks for how many unique client IP addresses access the file, uses access_threshold
script_data_query = f'''
let scriptExtensions = dynamic([".php", ".jsp", ".aspx", ".asmx", ".asax", ".cfm", ".shtml"]);
let alldata = W3CIISLog
| where TimeGenerated >= datetime("{creationtime}") - 30s
| where TimeGenerated <= datetime("{creationtime}") + 30s
| where csUriStem has_any(scriptExtensions)
| extend splitUriStem = split(csUriStem, "/")
| extend FileName = splitUriStem[-1] | extend firstDir = splitUriStem[-2]
| summarize StartTime=min(TimeGenerated), EndTime=max(TimeGenerated) by AttackerIP=cIP, AttackerUserAgent=csUserAgent, csUriStem, filename=tostring(FileName), tostring(firstDir)
| order by StartTime asc;
let fileprev = W3CIISLog
| summarize accessCount=dcount(cIP) by csUriStem;
alldata
| join (
fileprev
) on csUriStem
| extend ShellLocation = csUriStem
| project-away csUriStem, csUriStem1
| where accessCount <= {access_threshold}
'''
aspx_data = qry_prov.exec_query(script_data_query)
if isinstance(aspx_data, pd.DataFrame) and not aspx_data.empty:
display(HTML('<h2>Step 3.3: File to investigate</h2><p>The files in the drop down below were accessed on the web server (and are therefore in W3CIIS Log) within 30 seconds of the command executing.</p><p>By default the notebook will only show files that have been accessed by a single client IP or UA.</p>'))
shells = aspx_data['ShellLocation']
pick_shell = widgets.Dropdown(
options=shells,
decription="Webshells",
disabled=False,
)
aspx_data_display = aspx_data
aspx_data_display = aspx_data_display.drop(['AttackerIP', 'AttackerUserAgent', 'firstDir', 'EndTime'], axis=1)
aspx_data_display.rename(columns={'filename':'ShellName', 'StartTime':'AccessTime'}, inplace=True)
display(HTML('Please select which file you would like to investigate:'))
#display(aspx_data)
display(pick_shell)
display(HTML('<hr><h2>Step 3.4: Enrich</h2>'))
else:
if aspx_data.empty:
md_warn('No events were found in W3CIISLog. Continuing will result in errors.')
else:
md_warn('The query failed, it may have timed out. Continuing will result in errors.')
if isinstance(aspx_data, pd.DataFrame) and not aspx_data.empty:
dfindex = pick_shell.index
filename = aspx_data.loc[[dfindex]]['filename'].values[0]
timegenerated = aspx_data.loc[[dfindex]]['StartTime'].values[0]
#Check the directory matches
first_directory = aspx_data.loc[[dfindex]]['firstDir'].values[0]
iis_query = f'''
let scriptExtensions = dynamic([".php", ".jsp", ".js", ".aspx", ".asmx", ".asax", ".cfm", ".shtml"]);
W3CIISLog
| where TimeGenerated >= datetime("{timegenerated}") - 30s
| where TimeGenerated <= datetime("{timegenerated}") + 30s
| where csUriStem has_any(scriptExtensions)
| extend splitUriStem = split(csUriStem, "/")
| extend FileName = splitUriStem[-1] | extend firstDir = splitUriStem[-2]
| where FileName == "{filename}" and firstDir == "{first_directory}"
| summarize StartTime=min(TimeGenerated), EndTime=max(TimeGenerated) by AttackerIP=cIP, AttackerUserAgent=csUserAgent, SiteName=sSiteName, ShellLocation=csUriStem
| order by StartTime asc
'''
iis_data = qry_prov.exec_query(iis_query)
else:
md_warn('There is no data in an object that should have data. A previous step has likely failed, we cannot continue.')
if isinstance(iis_data, pd.DataFrame) and not iis_data.empty:
display(HTML('<div style="border-left: 6px solid #ccc; border-left-width: 6px; border-left-style: solid; padding: 0.01em 16px; border-color:#00cc69; background-color:#e6fff3;"><h3>Enrichment complete!<br> Please <a href="#Step-4:-Find-the-Attacker">click here</a> to continue your investigation.</h3><br></div><hr>'))
else:
if iis_data.empty:
md_warn('No events were found in W3CIISLog. Continuing will result in errors.')
else:
md_warn('The query failed, it may have timed out. Continuing will result in errors.')
attackerip = iis_data['AttackerIP']
attackerua = iis_data['AttackerUserAgent']
pick_ip = widgets.Dropdown(
options=attackerip,
decription="IP Addresses",
disabled=False,
)
pick_ua = widgets.Dropdown(
options=attackerua,
decription="User Agents",
disabled=False,
)
pick_window = widgets.Dropdown(
options=['30m','1h','5h','7h', '1d', '3d','7d'],
decription="Window",
disabled=False,
)
pick_investigation = widgets.Dropdown(
options=['Investigate Both', 'Investigate IP','Investigate Useragent'],
decription="What should we investigatew?",
disabled=False,
)
display(HTML('<h2>Candidate Attacker IP Addresses</h2>'))
md('The following attacker IP addresses accessed the webshell during the alert window, continue to Step 5 to choose which to investigate.')
display(iis_data)
display(HTML('<hr><br><h2>Step 5: Select Investigation Parameters</h2>'))
display(HTML('<h3>Attacker To Investigate</h3><p>Now it is time to hone in on our attacker. If you have multiple attacker indicators you can repeat from this step.</p><p>Select parameters to investigate, the default selection is the earliest access within the alert window:</p>'))
display(HBox([pick_ip, pick_ua, pick_investigation]))
widgets.jslink((pick_ip, 'index'), (pick_ua, 'index'))
display(HTML('<h3>Previous file access window</h3><p>To determine what files were accessed immediately before the shell, please pick the window we\'ll use to look back:</p>'))
display(pick_window)
display(HTML('<hr><h2>Step 6: Collect Attacker Enrichments</h2><p>Finally execute the below cell to collect additional details about the attacker.</p>'))
queryWindow = pick_window.value # Lookback window
investigation_param = pick_investigation.index # 0 = both, 1 = ip, 2 = ua
dfindex = pick_ip.index # contains dataframe index (int)
attackerip = str(pick_ip.value)
attackerua = iis_data.loc[[dfindex]]['AttackerUserAgent'].values[0]
attackertime = iis_data.loc[[dfindex]]['StartTime'].values[0]
sitename = iis_data.loc[[dfindex]]['SiteName'].values[0]
shell_location = iis_data.loc[[dfindex]]['ShellLocation'].values[0]
access_data = ['','']
first_server_access_data = ['','']
def iis_access_ip():
iis_access_ip = f'''
let scriptExtensions = dynamic([".php", ".jsp", ".js", ".aspx", ".asmx", ".asax", ".cfm", ".shtml"]);
W3CIISLog
| where TimeGenerated >= datetime("{attackertime}") - {queryWindow}
| where TimeGenerated <= datetime("{attackertime}")
| where sSiteName == "{sitename}"
| where cIP == "{attackerip}"
| order by TimeGenerated desc
| project TimeAccessed=TimeGenerated, SiteName=sSiteName, ServerIP=sIP, FilesTouched=csUriStem, AttackerP=cIP
| where FilesTouched has_any(scriptExtensions)
| order by TimeAccessed asc
'''
#Find the first time the attacker accessed the webserver
first_server_access_ip = f'''
W3CIISLog
| where TimeGenerated > ago(30d)
| where sSiteName == "{sitename}"
| where cIP == "{attackerip}"
| order by TimeGenerated asc
| take 1
| project TimeAccessed=TimeGenerated, Site=sSiteName, FileAccessed=csUriStem
| order by TimeAccessed asc
'''
access_data = qry_prov.exec_query(iis_access_ip)
first_server_access_data = qry_prov.exec_query(first_server_access_ip)
return access_data, first_server_access_data
def iis_access_ua():
iis_access_ua = f'''
let scriptExtensions = dynamic([".php", ".jsp", ".js", ".aspx", ".asmx", ".asax", ".cfm", ".shtml"]);
W3CIISLog
| where TimeGenerated >= datetime("{attackertime}") - {queryWindow}
| where TimeGenerated <= datetime("{attackertime}")
| where sSiteName == "{sitename}"
| where csUserAgent == "{attackerua}"
| order by TimeGenerated desc
| project TimeAccessed=TimeGenerated, SiteName=sSiteName, ServerIP=sIP, FilesTouched=csUriStem, AttackerP=cIP, AttackerUserAgent=csUserAgent
| where FilesTouched has_any(scriptExtensions)
| order by TimeAccessed asc
'''
#Find the first time the attacker accessed the webserver
first_server_access_ua = f'''
W3CIISLog
| where TimeGenerated > ago(30d)
| where sSiteName == "{sitename}"
| where csUserAgent == "{attackerua}"
| order by TimeGenerated asc
| take 1
| project TimeAccessed=TimeGenerated, Site=sSiteName, FileAccessed=csUriStem
| order by TimeAccessed asc
'''
access_data = qry_prov.exec_query(iis_access_ua)
first_server_access_data = qry_prov.exec_query(first_server_access_ua)
return access_data, first_server_access_data
if investigation_param == 1:
display(HTML('<p>Querying for attacker IP</p>'))
result = iis_access_ip()
access_data[0] = result[0]
first_server_access_data[0] = result[1]
first_shell_index = access_data[0][access_data[0].FilesTouched==shell_location].first_valid_index()
elif investigation_param == 2:
display(HTML('<p>Querying for attacker UA</p>'))
result = iis_access_ua()
access_data[1] = result[0]
first_server_access_data[1] = result[1]
first_shell_index = access_data[1][access_data[1].FilesTouched==shell_location].first_valid_index()
elif investigation_param == 0:
display(HTML('<p>Querying for attacker IP and UA</p>'))
result_ip = iis_access_ip()
result_ua = iis_access_ua()
access_data[0] = result_ip[0]
access_data[1] = result_ua[0]
first_server_access_data[0] = result_ip[1]
first_server_access_data[1] = result_ua[1]
first_shell_index = access_data[0][access_data[0].FilesTouched==shell_location].first_valid_index()
first_shell_index_ua = access_data[1][access_data[1].FilesTouched==shell_location].first_valid_index()
display(HTML('<div style="border-left: 6px solid #ccc; border-left-width: 6px; border-left-style: solid; padding: 0.01em 16px; border-color:#00cc69; background-color:#e6fff3;"><h3>Enrichment complete!</h3><p>Continue to generate your report</p><br></div><hr><h2>Step 7: Generate Report</h2>'))
attackerua = attackerua.replace("+", " ")
display(HTML(f'''
<h2> Attack Summary</h2>
<div style="border-left: 6px solid #ccc; border-left-width: 6px; border-left-style: solid; padding: 0.01em 16px; border-color:#1aa3ff; background-color: #f2f2f2;">
<p></p>
<p><b>Attacker IP: </b>{attackerip}</p>
<p><b>Attacker user agent: </b>{attackerua}</p>
<p><b>Webshell installed: </b>{shell_location}</p>
<p><b>Victim site: </b>{sitename}</p>
<br>
</div>
'''))
look_back = 0
# No results
if first_shell_index is None:
first_shell_index = 0
# Our default look back is 5 files, if there are not 5 files we take what we can get
elif first_shell_index < 5:
look_back = first_shell_index
else:
look_back = 5
if investigation_param == 1:
display(HTML('<h2>File history</h2>'))
if first_shell_index > 0:
print('The files the attacker IP \"'+attackerip+'\" accessed prior to the webshell installation were:')
display(access_data[0][first_shell_index-look_back:first_shell_index+1])
else:
print(f'No files were access by the attacker prior to webshell install, try expaning the query window (currently:{queryWindow})')
display(HTML('<h2>Earliest access</h2><p>In the last 30 days the earliest known access to the server from the attacker IP was:</p>'))
display(first_server_access_data[0])
elif investigation_param == 2:
display(HTML('<h2>File history</h2>'))
if first_shell_index > 0:
print('The files the attacker UA \"'+attackerua+'\" accessed prior to the webshell installation were:')
display(access_data[1][first_shell_index-look_back:first_shell_index+1])
else:
print(f'No files were access by the attacker prior to webshell install, try expaning the query window (currently:{queryWindow})')
display(HTML('<h2>Earliest access</h2><p>In the last 30 days the earliest known access to the server from the attacker UA was:</p>'))
display(first_server_access_data[1])
elif investigation_param == 0:
look_back_ua = 0
if first_shell_index_ua is None:
first_shell_index_ua = 0
elif first_shell_index_ua < 5:
look_back_ua = first_shell_index_ua
else:
look_back_ua = 5
display(HTML('<h2>File history</h2>'))
if first_shell_index > 0 or first_shell_index_ua > 0:
print('The files the attacker IP \"'+attackerip+'\" accessed prior to the webshell installation were:')
display(access_data[0][first_shell_index-look_back:first_shell_index+1])
print('The files the attacker UA \"'+attackerua+'\" accessed prior to the webshell installation were:')
display(access_data[1][first_shell_index_ua-look_back_ua:first_shell_index_ua+1])
else:
print(f'No files were access by the attacker prior to webshell install, try expanding the query window (currently:{queryWindow})')
display(HTML('<h2>Earliest access</h2><p>In the last 30 days the earliest known access to the server from the attacker IP was:</p>'))
display(first_server_access_data[0])
display(HTML('<p>In the last 30 days the earliest known access to the server from the attacker UA was:</p>'))
display(first_server_access_data[1])