cloudflare/Azure-Sentinel
Publicmirrored fromhttps://github.com/cloudflare/Azure-Sentinel
Notebooks/Guided Hunting - Windows-Host-Explorer.ipynb
1331lines · modecode
7 years ago
| 1 | { |
| 2 | "cells": [ |
| 3 | { |
| 4 | "cell_type": "markdown", |
| 5 | "metadata": {}, |
| 6 | "source": [ |
| 7 | "# Title: Windows Host Explorer\n", |
| 8 | "**Notebook Version:** 1.0<br>\n", |
| 9 | "**Python Version:** Python 3.6 (including Python 3.6 - AzureML)<br>\n", |
| 10 | "**Required Packages**: kqlmagic, msticpy, pandas, numpy, matplotlib, networkx, ipywidgets, ipython, scikit_learn, dnspython, ipwhois, folium, maxminddb_geolite2, holoviews<br>\n", |
| 11 | "**Platforms Supported**:\n", |
| 12 | "- Azure Notebooks Free Compute\n", |
| 13 | "- Azure Notebooks DSVM\n", |
| 14 | "- OS Independent\n", |
| 15 | "\n", |
| 16 | "**Data Sources Required**:\n", |
| 17 | "- Log Analytics - SecurityAlert, SecurityEvent (EventIDs 4688 and 4624/25), AzureNetworkAnalytics_CL, Heartbeat\n", |
| 18 | "- (Optional) - VirusTotal (with API key)\n", |
| 19 | "\n", |
| 20 | "## Description:\n", |
| 21 | "Brings together a series of queries and visualizations to help you determine the security state of the Windows host or virtual machine that you are investigating.\n" |
| 22 | ] |
| 23 | }, |
| 24 | { |
| 25 | "cell_type": "markdown", |
| 26 | "metadata": {}, |
| 27 | "source": [ |
| 28 | "<a id='toc'></a>\n", |
| 29 | "# Table of Contents\n", |
| 30 | "- [Setup and Authenticate](#setup)\n", |
| 31 | "\n", |
| 32 | "- [Get Host Name](#get_hostname)\n", |
| 33 | "- [Related Alerts](#related_alerts)\n", |
| 34 | "- [Host Logons](#host_logons)\n", |
| 35 | " - [Failed Logons](#failed_logons)\n", |
| 36 | " - [Session Processes](#examine_win_logon_sess)\n", |
| 37 | "- [Check for IOCs in Commandline](#cmdlineiocs)\n", |
| 38 | " - [VirusTotal lookup](#virustotallookup)\n", |
| 39 | "- [Network Data](#comms_to_other_hosts)\n", |
| 40 | "- [Appendices](#appendices)\n", |
| 41 | " - [Saving data to Excel](#appendices)\n" |
| 42 | ] |
| 43 | }, |
| 44 | { |
| 45 | "cell_type": "markdown", |
| 46 | "metadata": {}, |
| 47 | "source": [ |
| 48 | "<a id='setup'></a>[Contents](#toc)\n", |
| 49 | "# Setup\n", |
| 50 | "\n", |
| 51 | "Make sure that you have installed packages specified in the setup (uncomment the lines to execute)\n", |
| 52 | "\n", |
| 53 | "## Install Packages\n", |
| 54 | "The first time this cell runs for a new Azure Notebooks project or local Python environment it will take several minutes to download and install the packages. In subsequent runs it should run quickly and confirm that package dependencies are already installed. Unless you want to upgrade the packages you can feel free to skip execution of the next cell.\n", |
| 55 | "\n", |
| 56 | "If you see any import failures (```ImportError```) in the notebook, please re-run this cell and answer 'y', then re-run the cell where the failure occurred.\n", |
| 57 | "\n", |
| 58 | "Note you may see some warnings about package incompatibility with certain packages. This does not affect the functionality of this notebook but you may need to upgrade the packages producing the warnings to a more recent version." |
| 59 | ] |
| 60 | }, |
| 61 | { |
| 62 | "cell_type": "code", |
| 63 | "execution_count": null, |
| 64 | "metadata": {}, |
| 65 | "outputs": [], |
| 66 | "source": [ |
| 67 | "import sys\n", |
| 68 | "import warnings\n", |
| 69 | "\n", |
| 70 | "warnings.filterwarnings(\"ignore\",category=DeprecationWarning)\n", |
| 71 | "\n", |
| 72 | "MIN_REQ_PYTHON = (3,6)\n", |
| 73 | "if sys.version_info < MIN_REQ_PYTHON:\n", |
| 74 | " print('Check the Kernel->Change Kernel menu and ensure that Python 3.6')\n", |
| 75 | " print('or later is selected as the active kernel.')\n", |
| 76 | " sys.exit(\"Python %s.%s or later is required.\\n\" % MIN_REQ_PYTHON)\n", |
| 77 | "\n", |
| 78 | "# Package Installs - try to avoid if they are already installed\n", |
| 79 | "try:\n", |
| 80 | " import msticpy.sectools as sectools\n", |
| 81 | " import Kqlmagic\n", |
| 82 | " from dns import reversename, resolver\n", |
| 83 | " from ipwhois import IPWhois\n", |
| 84 | " import folium\n", |
| 85 | " \n", |
| 86 | " print('If you answer \"n\" this cell will exit with an error in order to avoid the pip install calls,')\n", |
| 87 | " print('This error can safely be ignored.')\n", |
| 88 | " resp = input('msticpy and Kqlmagic packages are already loaded. Do you want to re-install? (y/n)')\n", |
| 89 | " if resp.strip().lower() != 'y':\n", |
| 90 | " sys.exit('pip install aborted - you may skip this error and continue.')\n", |
| 91 | " else:\n", |
| 92 | " print('After installation has completed, restart the current kernel and run '\n", |
| 93 | " 'the notebook again skipping this cell.')\n", |
| 94 | "except ImportError:\n", |
| 95 | " pass\n", |
| 96 | "\n", |
| 97 | "print('\\nPlease wait. Installing required packages. This may take a few minutes...')\n", |
| 98 | "!pip install git+https://github.com/microsoft/msticpy --upgrade --user\n", |
| 99 | "!pip install Kqlmagic --no-cache-dir --upgrade --user\n", |
| 100 | "!pip install seaborn --upgrade --user\n", |
| 101 | "!pip install holoviews --upgrade --user\n", |
| 102 | "!pip install dnspython --upgrade --user \n", |
| 103 | "!pip install ipwhois --upgrade --user \n", |
| 104 | "!pip install folium --upgrade --user\n", |
| 105 | "\n", |
| 106 | "# Uncomment to refresh the maxminddb database\n", |
| 107 | "# !pip install maxminddb-geolite2 --upgrade \n", |
| 108 | "print('To ensure that the latest versions of the installed libraries '\n", |
| 109 | " 'are used, please restart the current kernel and run '\n", |
| 110 | " 'the notebook again skipping this cell.')" |
| 111 | ] |
| 112 | }, |
| 113 | { |
| 114 | "cell_type": "code", |
| 115 | "execution_count": null, |
| 116 | "metadata": { |
| 117 | "scrolled": true |
| 118 | }, |
| 119 | "outputs": [], |
| 120 | "source": [ |
| 121 | "# Imports\n", |
| 122 | "import sys\n", |
| 123 | "import warnings\n", |
| 124 | "\n", |
| 125 | "MIN_REQ_PYTHON = (3,6)\n", |
| 126 | "if sys.version_info < MIN_REQ_PYTHON:\n", |
| 127 | " print('Check the Kernel->Change Kernel menu and ensure that Python 3.6')\n", |
| 128 | " print('or later is selected as the active kernel.')\n", |
| 129 | " sys.exit(\"Python %s.%s or later is required.\\n\" % MIN_REQ_PYTHON)\n", |
| 130 | "\n", |
| 131 | "import numpy as np\n", |
| 132 | "from IPython import get_ipython\n", |
| 133 | "from IPython.display import display, HTML, Markdown\n", |
| 134 | "import ipywidgets as widgets\n", |
| 135 | "\n", |
| 136 | "import matplotlib.pyplot as plt\n", |
| 137 | "import seaborn as sns\n", |
| 138 | "sns.set()\n", |
| 139 | "import networkx as nx\n", |
| 140 | "\n", |
| 141 | "import pandas as pd\n", |
| 142 | "pd.set_option('display.max_rows', 100)\n", |
| 143 | "pd.set_option('display.max_columns', 50)\n", |
| 144 | "pd.set_option('display.max_colwidth', 100)\n", |
| 145 | "\n", |
| 146 | "import msticpy.sectools as sectools\n", |
| 147 | "import msticpy.nbtools as mas\n", |
| 148 | "import msticpy.nbtools.kql as qry\n", |
| 149 | "import msticpy.nbtools.nbdisplay as nbdisp\n", |
| 150 | "\n", |
| 151 | "WIDGET_DEFAULTS = {'layout': widgets.Layout(width='95%'),\n", |
| 152 | " 'style': {'description_width': 'initial'}}\n", |
| 153 | "\n", |
| 154 | "# Some of our dependencies (networkx) still use deprecated Matplotlib\n", |
| 155 | "# APIs - we can't do anything about it so suppress them from view\n", |
| 156 | "from matplotlib import MatplotlibDeprecationWarning\n", |
| 157 | "warnings.simplefilter(\"ignore\", category=MatplotlibDeprecationWarning)\n", |
| 158 | "\n", |
| 159 | "display(HTML(mas.util._TOGGLE_CODE_PREPARE_STR))\n", |
| 160 | "HTML('''\n", |
| 161 | " <script type=\"text/javascript\">\n", |
| 162 | " IPython.notebook.kernel.execute(\"nb_query_string='\".concat(window.location.search).concat(\"'\"));\n", |
| 163 | " </script>\n", |
| 164 | " ''');" |
| 165 | ] |
| 166 | }, |
| 167 | { |
| 168 | "cell_type": "markdown", |
| 169 | "metadata": { |
| 170 | "tags": [ |
| 171 | "remove" |
| 172 | ] |
| 173 | }, |
| 174 | "source": [ |
| 175 | "### Get WorkspaceId\n", |
| 176 | "To find your Workspace Id go to [Log Analytics](https://ms.portal.azure.com/#blade/HubsExtension/Resources/resourceType/Microsoft.OperationalInsights%2Fworkspaces). Look at the workspace properties to find the ID." |
| 177 | ] |
| 178 | }, |
| 179 | { |
| 180 | "cell_type": "code", |
| 181 | "execution_count": null, |
| 182 | "metadata": { |
| 183 | "tags": [ |
| 184 | "todo" |
| 185 | ] |
| 186 | }, |
| 187 | "outputs": [], |
| 188 | "source": [ |
| 189 | "import os\n", |
| 190 | "from msticpy.nbtools.wsconfig import WorkspaceConfig\n", |
| 191 | "ws_config_file = 'config.json'\n", |
| 192 | "\n", |
| 193 | "WORKSPACE_ID = None\n", |
| 194 | "TENANT_ID = None\n", |
| 195 | "try:\n", |
| 196 | " ws_config = WorkspaceConfig(ws_config_file)\n", |
| 197 | " display(Markdown(f'Read Workspace configuration from local config.json for workspace **{ws_config[\"workspace_name\"]}**'))\n", |
| 198 | " for cf_item in ['tenant_id', 'subscription_id', 'resource_group', 'workspace_id', 'workspace_name']:\n", |
| 199 | " display(Markdown(f'**{cf_item.upper()}**: {ws_config[cf_item]}'))\n", |
| 200 | " \n", |
| 201 | " if ('cookiecutter' not in ws_config['workspace_id'] or\n", |
| 202 | " 'cookiecutter' not in ws_config['tenant_id']):\n", |
| 203 | " WORKSPACE_ID = ws_config['workspace_id']\n", |
| 204 | " TENANT_ID = ws_config['tenant_id']\n", |
| 205 | "except:\n", |
| 206 | " pass\n", |
| 207 | "\n", |
| 208 | "if not WORKSPACE_ID or not TENANT_ID:\n", |
| 209 | " display(Markdown('**Workspace configuration not found.**\\n\\n'\n", |
| 210 | " 'Please go to your Log Analytics workspace, copy the workspace ID'\n", |
| 211 | " ' and/or tenant Id and paste here.<br> '\n", |
| 212 | " 'Or read the workspace_id from the config.json in your Azure Notebooks project.'))\n", |
| 213 | " ws_config = None\n", |
| 214 | " ws_id = mas.GetEnvironmentKey(env_var='WORKSPACE_ID',\n", |
| 215 | " prompt='Please enter your Log Analytics Workspace Id:', auto_display=True)\n", |
| 216 | " ten_id = mas.GetEnvironmentKey(env_var='TENANT_ID',\n", |
| 217 | " prompt='Please enter your Log Analytics Tenant Id:', auto_display=True)" |
| 218 | ] |
| 219 | }, |
| 220 | { |
| 221 | "cell_type": "markdown", |
| 222 | "metadata": {}, |
| 223 | "source": [ |
| 224 | "### Authenticate to Log Analytics\n", |
| 225 | "If you are using user/device authentication, run the following cell. \n", |
| 226 | "- Click the 'Copy code to clipboard and authenticate' button.\n", |
| 227 | "- This will pop up an Azure Active Directory authentication dialog (in a new tab or browser window). The device code will have been copied to the clipboard. \n", |
| 228 | "- Select the text box and paste (Ctrl-V/Cmd-V) the copied value. \n", |
| 229 | "- You should then be redirected to a user authentication page where you should authenticate with a user account that has permission to query your Log Analytics workspace.\n", |
| 230 | "\n", |
| 231 | "Use the following syntax if you are authenticating using an Azure Active Directory AppId and Secret:\n", |
| 232 | "```\n", |
| 233 | "%kql loganalytics://tenant(aad_tenant).workspace(WORKSPACE_ID).clientid(client_id).clientsecret(client_secret)\n", |
| 234 | "```\n", |
| 235 | "instead of\n", |
| 236 | "```\n", |
| 237 | "%kql loganalytics://code().workspace(WORKSPACE_ID)\n", |
| 238 | "```\n", |
| 239 | "\n", |
| 240 | "Note: you may occasionally see a JavaScript error displayed at the end of the authentication - you can safely ignore this.<br>\n", |
| 241 | "On successful authentication you should see a ```popup schema``` button." |
| 242 | ] |
| 243 | }, |
| 244 | { |
| 245 | "cell_type": "code", |
| 246 | "execution_count": null, |
| 247 | "metadata": { |
| 248 | "tags": [ |
| 249 | "todo" |
| 250 | ] |
| 251 | }, |
| 252 | "outputs": [], |
| 253 | "source": [ |
| 254 | "if not WORKSPACE_ID or not TENANT_ID:\n", |
| 255 | " try:\n", |
| 256 | " WORKSPACE_ID = ws_id.value\n", |
| 257 | " TENANT_ID = ten_id.value\n", |
| 258 | " except NameError:\n", |
| 259 | " raise ValueError('No workspace or Tenant Id.')\n", |
| 260 | "\n", |
| 261 | "mas.kql.load_kql_magic()\n", |
| 262 | "%kql loganalytics://code().tenant(TENANT_ID).workspace(WORKSPACE_ID)\n" |
| 263 | ] |
| 264 | }, |
| 265 | { |
| 266 | "cell_type": "code", |
| 267 | "execution_count": null, |
| 268 | "metadata": {}, |
| 269 | "outputs": [], |
| 270 | "source": [ |
| 271 | "%kql search * | summarize RowCount=count() by Type | project-rename Table=Type\n", |
| 272 | "la_table_set = _kql_raw_result_.to_dataframe()\n", |
| 273 | "table_index = la_table_set.set_index('Table')['RowCount'].to_dict()\n", |
| 274 | "display(Markdown('Current data in workspace'))\n", |
| 275 | "display(la_table_set.T)" |
| 276 | ] |
| 277 | }, |
| 278 | { |
| 279 | "cell_type": "markdown", |
| 280 | "metadata": {}, |
| 281 | "source": [ |
| 282 | "<a id='get_hostname'></a>[Contents](#toc)\n", |
| 283 | "# Enter the host name and query time window" |
| 284 | ] |
| 285 | }, |
| 286 | { |
| 287 | "cell_type": "code", |
| 288 | "execution_count": null, |
| 289 | "metadata": {}, |
| 290 | "outputs": [], |
| 291 | "source": [ |
| 292 | "host_text = widgets.Text(description='Enter the Host name to search for:', **WIDGET_DEFAULTS)\n", |
| 293 | "display(host_text)" |
| 294 | ] |
| 295 | }, |
| 296 | { |
| 297 | "cell_type": "code", |
| 298 | "execution_count": null, |
| 299 | "metadata": {}, |
| 300 | "outputs": [], |
| 301 | "source": [ |
| 302 | "query_times = mas.QueryTime(units='day', max_before=20, before=5, max_after=1)\n", |
| 303 | "query_times.display()" |
| 304 | ] |
| 305 | }, |
| 306 | { |
| 307 | "cell_type": "code", |
| 308 | "execution_count": null, |
| 309 | "metadata": { |
| 310 | "scrolled": true |
| 311 | }, |
| 312 | "outputs": [], |
| 313 | "source": [ |
| 314 | "from msticpy.nbtools.entityschema import GeoLocation\n", |
| 315 | "from msticpy.sectools.geoip import GeoLiteLookup\n", |
| 316 | "iplocation = GeoLiteLookup()\n", |
| 317 | "\n", |
| 318 | "# Get single event - try process creation\n", |
| 319 | "if 'SecurityEvent' not in table_index:\n", |
| 320 | " raise ValueError('No Windows event log data available in the workspace')\n", |
| 321 | "start = f'\\'{query_times.start}\\''\n", |
| 322 | "hostname = host_text.value\n", |
| 323 | "find_host_event_query = r'''\n", |
| 324 | "SecurityEvent\n", |
| 325 | "| where TimeGenerated >= datetime({start})\n", |
| 326 | "| where TimeGenerated <= datetime({end})\n", |
| 327 | "| where Computer has '{hostname}'\n", |
| 328 | "| top 1 by TimeGenerated desc nulls last\n", |
| 329 | "'''.format(start=query_times.start,\n", |
| 330 | " end=query_times.end,\n", |
| 331 | " hostname=hostname)\n", |
| 332 | "\n", |
| 333 | "print('Checking for event data...')\n", |
| 334 | "# Get heartbeat event if available\n", |
| 335 | "%kql -query find_host_event_query\n", |
| 336 | "if _kql_raw_result_.completion_query_info['StatusCode'] == 0:\n", |
| 337 | " host_event_df = _kql_raw_result_.to_dataframe()\n", |
| 338 | "\n", |
| 339 | "host_event = None\n", |
| 340 | "host_entity = None\n", |
| 341 | "if host_event_df.shape[0] > 0:\n", |
| 342 | " host_entity = mas.Host(src_event=host_event_df.iloc[0])\n", |
| 343 | "if not host_entity:\n", |
| 344 | " raise LookupError(f'Could not find Windows events the name {hostname}')\n", |
| 345 | " \n", |
| 346 | "# Try to get an OMS Heartbeat for this computer\n", |
| 347 | "if 'Heartbeat' in table_index:\n", |
| 348 | " \n", |
| 349 | " heartbeat_query = '''\n", |
| 350 | "Heartbeat \n", |
| 351 | "| where Computer == \\'{computer}\\' \n", |
| 352 | "| where TimeGenerated >= datetime({start})\n", |
| 353 | "| where TimeGenerated <= datetime({end})\n", |
| 354 | "| top 1 by TimeGenerated desc nulls last\n", |
| 355 | "'''.format(start=query_times.start,\n", |
| 356 | " end=query_times.end,\n", |
| 357 | " computer=host_entity.computer)\n", |
| 358 | "\n", |
| 359 | " print('Getting heartbeat data...')\n", |
| 360 | " %kql -query heartbeat_query\n", |
| 361 | "\n", |
| 362 | " if _kql_raw_result_.completion_query_info['StatusCode'] == 0:\n", |
| 363 | " host_hb = _kql_raw_result_.to_dataframe().iloc[0]\n", |
| 364 | " host_entity.SourceComputerId = host_hb['SourceComputerId']\n", |
| 365 | " host_entity.OSType = host_hb['OSType']\n", |
| 366 | " host_entity.OSMajorVersion = host_hb['OSMajorVersion']\n", |
| 367 | " host_entity.OSMinorVersion = host_hb['OSMinorVersion']\n", |
| 368 | " host_entity.ComputerEnvironment = host_hb['ComputerEnvironment']\n", |
| 369 | " host_entity.OmsSolutions = [sol.strip() for sol in host_hb['Solutions'].split(',')]\n", |
| 370 | " host_entity.VMUUID = host_hb['VMUUID']\n", |
| 371 | "\n", |
| 372 | " ip_entity = mas.IpAddress()\n", |
| 373 | " ip_entity.Address = host_hb['ComputerIP'] \n", |
| 374 | " geoloc_entity = GeoLocation()\n", |
| 375 | " geoloc_entity.CountryName = host_hb['RemoteIPCountry'] \n", |
| 376 | " geoloc_entity.Longitude = host_hb['RemoteIPLongitude']\n", |
| 377 | " geoloc_entity.Latitude = host_hb['RemoteIPLatitude']\n", |
| 378 | " ip_entity.Location = geoloc_entity\n", |
| 379 | " host_entity.IPAddress = ip_entity # TODO change to graph edge \n", |
| 380 | "\n", |
| 381 | "if 'AzureNetworkAnalytics_CL' in table_index:\n", |
| 382 | " print('Looking for IP addresses in network flows...')\n", |
| 383 | " aznet_query = '''\n", |
| 384 | "AzureNetworkAnalytics_CL\n", |
| 385 | "| where TimeGenerated >= datetime({start})\n", |
| 386 | "| where TimeGenerated <= datetime({end})\n", |
| 387 | "| where VirtualMachine_s has \\'{host}\\'\n", |
| 388 | "| where ResourceType == 'NetworkInterface'\n", |
| 389 | "| top 1 by TimeGenerated desc\n", |
| 390 | "| project PrivateIPAddresses = PrivateIPAddresses_s, \n", |
| 391 | " PublicIPAddresses = PublicIPAddresses_s\n", |
| 392 | "'''.format(start=query_times.start,\n", |
| 393 | " end=query_times.end,\n", |
| 394 | " host=host_entity.HostName)\n", |
| 395 | " %kql -query aznet_query\n", |
| 396 | " az_net_df = _kql_raw_result_.to_dataframe()\n", |
| 397 | "\n", |
| 398 | " def convert_to_ip_entities(ip_str):\n", |
| 399 | " ip_entities = []\n", |
| 400 | " if ip_str:\n", |
| 401 | " if ',' in ip_str:\n", |
| 402 | " addrs = ip_str.split(',')\n", |
| 403 | " elif ' ' in ip_str:\n", |
| 404 | " addrs = ip_str.split(' ')\n", |
| 405 | " else:\n", |
| 406 | " addrs = [ip_str]\n", |
| 407 | " for addr in addrs:\n", |
| 408 | " ip_entity = mas.IpAddress()\n", |
| 409 | " ip_entity.Address = addr.strip()\n", |
| 410 | " iplocation.lookup_ip(ip_entity=ip_entity)\n", |
| 411 | " ip_entities.append(ip_entity)\n", |
| 412 | " return ip_entities\n", |
| 413 | "\n", |
| 414 | " # Add this information to our inv_host_entity\n", |
| 415 | " retrieved_address=[]\n", |
| 416 | " if len(az_net_df) == 1:\n", |
| 417 | " priv_addr_str = az_net_df['PrivateIPAddresses'].loc[0]\n", |
| 418 | " host_entity.properties['private_ips'] = convert_to_ip_entities(priv_addr_str)\n", |
| 419 | "\n", |
| 420 | " pub_addr_str = az_net_df['PublicIPAddresses'].loc[0]\n", |
| 421 | " host_entity.properties['public_ips'] = convert_to_ip_entities(pub_addr_str)\n", |
| 422 | " retrieved_address = [ip.Address for ip in host_entity.properties['public_ips']]\n", |
| 423 | " else:\n", |
| 424 | " if 'private_ips' not in host_entity.properties:\n", |
| 425 | " host_entity.properties['private_ips'] = []\n", |
| 426 | " if 'public_ips' not in host_entity.properties:\n", |
| 427 | " host_entity.properties['public_ips'] = []\n", |
| 428 | " \n", |
| 429 | "print(host_entity)" |
| 430 | ] |
| 431 | }, |
| 432 | { |
| 433 | "cell_type": "markdown", |
| 434 | "metadata": { |
| 435 | "tags": [ |
| 436 | "todo" |
| 437 | ] |
| 438 | }, |
| 439 | "source": [ |
| 440 | "<a id='related_alerts'></a>[Contents](#toc)\n", |
| 441 | "# Related Alerts" |
| 442 | ] |
| 443 | }, |
| 444 | { |
| 445 | "cell_type": "code", |
| 446 | "execution_count": null, |
| 447 | "metadata": {}, |
| 448 | "outputs": [], |
| 449 | "source": [ |
| 450 | "# set the origin time to the time of our alert\n", |
| 451 | "query_times = mas.QueryTime(units='day', \n", |
| 452 | " max_before=28, max_after=1, before=5)\n", |
| 453 | "query_times.display()" |
| 454 | ] |
| 455 | }, |
| 456 | { |
| 457 | "cell_type": "code", |
| 458 | "execution_count": null, |
| 459 | "metadata": { |
| 460 | "scrolled": false |
| 461 | }, |
| 462 | "outputs": [], |
| 463 | "source": [ |
| 464 | "related_alerts_query = r'''\n", |
| 465 | "SecurityAlert\n", |
| 466 | "| where TimeGenerated >= datetime({start})\n", |
| 467 | "| where TimeGenerated <= datetime({end})\n", |
| 468 | "| extend StartTimeUtc = TimeGenerated\n", |
| 469 | "| extend AlertDisplayName = DisplayName\n", |
| 470 | "| extend Computer = '{host}'\n", |
| 471 | "| extend simple_hostname = tostring(split(Computer, '.')[0])\n", |
| 472 | "| where Entities has Computer or Entities has simple_hostname\n", |
| 473 | " or ExtendedProperties has Computer\n", |
| 474 | " or ExtendedProperties has simple_hostname\n", |
| 475 | "'''.format(start=query_times.start,\n", |
| 476 | " end=query_times.end,\n", |
| 477 | " host=host_entity.HostName)\n", |
| 478 | "%kql -query related_alerts_query\n", |
| 479 | "related_alerts = _kql_raw_result_.to_dataframe()\n", |
| 480 | "\n", |
| 481 | "if related_alerts is not None and not related_alerts.empty:\n", |
| 482 | " host_alert_items = (related_alerts[['AlertName', 'TimeGenerated']]\\\n", |
| 483 | " .groupby('AlertName').TimeGenerated.agg('count').to_dict())\n", |
| 484 | " # acct_alert_items = related_alerts\\\n", |
| 485 | " # .query('acct_match == @True')[['AlertType', 'StartTimeUtc']]\\\n", |
| 486 | " # .groupby('AlertType').StartTimeUtc.agg('count').to_dict()\n", |
| 487 | " # proc_alert_items = related_alerts\\\n", |
| 488 | " # .query('proc_match == @True')[['AlertType', 'StartTimeUtc']]\\\n", |
| 489 | " # .groupby('AlertType').StartTimeUtc.agg('count').to_dict()\n", |
| 490 | "\n", |
| 491 | " def print_related_alerts(alertDict, entityType, entityName):\n", |
| 492 | " if len(alertDict) > 0:\n", |
| 493 | " display(Markdown('### Found {} different alert types related to this {} (\\'{}\\')'.format(len(alertDict), entityType, entityName)))\n", |
| 494 | " for (k,v) in alertDict.items():\n", |
| 495 | " print('- {}, Count of alerts: {}'.format(k, v))\n", |
| 496 | " else:\n", |
| 497 | " print('No alerts for {} entity \\'{}\\''.format(entityType, entityName))\n", |
| 498 | "\n", |
| 499 | " print_related_alerts(host_alert_items, 'host', host_entity.HostName)\n", |
| 500 | "\n", |
| 501 | " nbdisp.display_timeline(data=related_alerts, title=\"Alerts\", source_columns=['AlertName'], height=200)\n", |
| 502 | "else:\n", |
| 503 | " display(Markdown('No related alerts found.'))" |
| 504 | ] |
| 505 | }, |
| 506 | { |
| 507 | "cell_type": "markdown", |
| 508 | "metadata": {}, |
| 509 | "source": [ |
| 510 | "## Browse List of Related Alerts\n", |
| 511 | "Select an Alert to view details" |
| 512 | ] |
| 513 | }, |
| 514 | { |
| 515 | "cell_type": "code", |
| 516 | "execution_count": null, |
| 517 | "metadata": { |
| 518 | "scrolled": false |
| 519 | }, |
| 520 | "outputs": [], |
| 521 | "source": [ |
| 522 | "def disp_full_alert(alert):\n", |
| 523 | " global related_alert\n", |
| 524 | " related_alert = mas.SecurityAlert(alert)\n", |
| 525 | " nbdisp.display_alert(related_alert, show_entities=True)\n", |
| 526 | "\n", |
| 527 | "if related_alerts is not None and not related_alerts.empty:\n", |
| 528 | " related_alerts['CompromisedEntity'] = related_alerts['Computer']\n", |
| 529 | " display(Markdown('### Click on alert to view details.'))\n", |
| 530 | " rel_alert_select = mas.AlertSelector(alerts=related_alerts, \n", |
| 531 | " # columns=['TimeGenerated', 'AlertName', 'CompromisedEntity', 'SystemAlertId'],\n", |
| 532 | " action=disp_full_alert)\n", |
| 533 | " rel_alert_select.display()\n" |
| 534 | ] |
| 535 | }, |
| 536 | { |
| 537 | "cell_type": "markdown", |
| 538 | "metadata": {}, |
| 539 | "source": [ |
| 540 | "<a id='host_logons'></a>[Contents](#toc)\n", |
| 541 | "# Host Logons" |
| 542 | ] |
| 543 | }, |
| 544 | { |
| 545 | "cell_type": "code", |
| 546 | "execution_count": null, |
| 547 | "metadata": {}, |
| 548 | "outputs": [], |
| 549 | "source": [ |
| 550 | "from msticpy.nbtools.query_defns import DataFamily, DataEnvironment\n", |
| 551 | "params_dict = {}\n", |
| 552 | "params_dict['host_filter_eq'] = f'Computer has \\'{host_entity.HostName}\\''\n", |
| 553 | "params_dict['host_filter_neq'] = f'Computer !has \\'{host_entity.HostName}\\''\n", |
| 554 | "params_dict['host_name'] = host_entity.HostName\n", |
| 555 | "params_dict['subscription_filter'] = 'true'\n", |
| 556 | "if host_entity.OSFamily == 'Linux':\n", |
| 557 | " params_dict['data_family'] = DataFamily.LinuxSecurity\n", |
| 558 | " params_dict['path_separator'] = '/'\n", |
| 559 | "else:\n", |
| 560 | " params_dict['data_family'] = DataFamily.WindowsSecurity\n", |
| 561 | " params_dict['path_separator'] = '\\\\'\n", |
| 562 | "\n", |
| 563 | "# set the origin time to the time of our alert\n", |
| 564 | "logon_query_times = mas.QueryTime(units='day',\n", |
| 565 | " before=5, after=1, max_before=20, max_after=20)\n", |
| 566 | "logon_query_times.display()" |
| 567 | ] |
| 568 | }, |
| 569 | { |
| 570 | "cell_type": "code", |
| 571 | "execution_count": null, |
| 572 | "metadata": { |
| 573 | "scrolled": false |
| 574 | }, |
| 575 | "outputs": [], |
| 576 | "source": [ |
| 577 | "\n", |
| 578 | "from msticpy.sectools.eventcluster import dbcluster_events, add_process_features, _string_score\n", |
| 579 | "\n", |
| 580 | "host_logons = qry.list_host_logons(provs=[logon_query_times], **params_dict)\n", |
| 581 | "\n", |
| 582 | "%matplotlib inline\n", |
| 583 | "\n", |
| 584 | "if host_logons is not None and not host_logons.empty:\n", |
| 585 | " logon_features = host_logons.copy()\n", |
| 586 | " logon_features['AccountNum'] = host_logons.apply(lambda x: _string_score(x.Account), axis=1)\n", |
| 587 | " logon_features['LogonIdNum'] = host_logons.apply(lambda x: _string_score(x.TargetLogonId), axis=1)\n", |
| 588 | " logon_features['LogonHour'] = host_logons.apply(lambda x: x.TimeGenerated.hour, axis=1)\n", |
| 589 | "\n", |
| 590 | " # you might need to play around with the max_cluster_distance parameter.\n", |
| 591 | " # decreasing this gives more clusters.\n", |
| 592 | " (clus_logons, _, _) = dbcluster_events(data=logon_features, time_column='TimeGenerated',\n", |
| 593 | " cluster_columns=['AccountNum',\n", |
| 594 | " 'LogonType'],\n", |
| 595 | " max_cluster_distance=0.0001)\n", |
| 596 | " display(Markdown(f'Number of input events: {len(host_logons)}'))\n", |
| 597 | " display(Markdown(f'Number of clustered events: {len(clus_logons)}'))\n", |
| 598 | " display(Markdown('### Distinct host logon patterns'))\n", |
| 599 | " clus_logons.sort_values('TimeGenerated')\n", |
| 600 | " nbdisp.display_logon_data(clus_logons)\n", |
| 601 | " \n", |
| 602 | " display(Markdown('### Logon timeline.'))\n", |
| 603 | " tooltip_cols = ['TargetUserName', 'TargetDomainName', 'SubjectUserName', \n", |
| 604 | " 'SubjectDomainName', 'LogonType', 'IpAddress']\n", |
| 605 | " nbdisp.display_timeline(data=host_logons.query('TargetLogonId != \"0x3e7\"'),\n", |
| 606 | " overlay_data=host_logons.query('TargetLogonId == \"0x3e7\"'),\n", |
| 607 | " title=\"Logons (blue=user, green=system)\", \n", |
| 608 | " source_columns=tooltip_cols, height=200)\n", |
| 609 | " \n", |
| 610 | " display(Markdown('### Counts of logon events by logon type.'))\n", |
| 611 | " display(Markdown('Min counts for each logon type highlighted.'))\n", |
| 612 | " logon_by_type = (host_logons[['Account', 'LogonType', 'EventID']]\n", |
| 613 | " .groupby(['Account','LogonType']).count().unstack()\n", |
| 614 | " .fillna(0)\n", |
| 615 | " .style\n", |
| 616 | " .background_gradient(cmap='viridis', low=.5, high=0)\n", |
| 617 | " .format(\"{0:0>3.0f}\"))\n", |
| 618 | " display(logon_by_type)\n", |
| 619 | " key = 'logon type key = {}'.format('; '.join([f'{k}: {v}' for k,v in mas.nbdisplay._WIN_LOGON_TYPE_MAP.items()]))\n", |
| 620 | " display(Markdown(key))\n", |
| 621 | " \n", |
| 622 | " display(Markdown('### Relative frequencies by account'))\n", |
| 623 | " plt.rcParams['figure.figsize'] = (12, 4)\n", |
| 624 | " clus_logons.plot.barh(x=\"Account\", y=\"ClusterSize\")\n", |
| 625 | "else:\n", |
| 626 | " display(Markdown('No logon events found for host.'))" |
| 627 | ] |
| 628 | }, |
| 629 | { |
| 630 | "cell_type": "markdown", |
| 631 | "metadata": { |
| 632 | "hidden": true |
| 633 | }, |
| 634 | "source": [ |
| 635 | "<a id='failed logons'></a>[Contents](#toc)\n", |
| 636 | "### Failed Logons" |
| 637 | ] |
| 638 | }, |
| 639 | { |
| 640 | "cell_type": "code", |
| 641 | "execution_count": null, |
| 642 | "metadata": { |
| 643 | "hidden": true, |
| 644 | "scrolled": true |
| 645 | }, |
| 646 | "outputs": [], |
| 647 | "source": [ |
| 648 | "failedLogons = qry.list_host_logon_failures(provs=[query_times], **params_dict)\n", |
| 649 | "if failedLogons.shape[0] == 0:\n", |
| 650 | " display(print('No logon failures recorded for this host between {security_alert.start} and {security_alert.start}'))\n", |
| 651 | "\n", |
| 652 | "failedLogons" |
| 653 | ] |
| 654 | }, |
| 655 | { |
| 656 | "cell_type": "markdown", |
| 657 | "metadata": {}, |
| 658 | "source": [ |
| 659 | "<a id='examine_win_logon_sess'></a>[Contents](#toc)\n", |
| 660 | "## Examine a Logon Session\n", |
| 661 | "\n", |
| 662 | "### Select a Logon ID To Examine" |
| 663 | ] |
| 664 | }, |
| 665 | { |
| 666 | "cell_type": "code", |
| 667 | "execution_count": null, |
| 668 | "metadata": {}, |
| 669 | "outputs": [], |
| 670 | "source": [ |
| 671 | "import re\n", |
| 672 | "dist_logons = clus_logons.sort_values('TimeGenerated')[['TargetUserName', 'TimeGenerated', \n", |
| 673 | " 'LastEventTime', 'LogonType', \n", |
| 674 | " 'ClusterSize']]\n", |
| 675 | "items = dist_logons.apply(lambda x: (f'{x.TargetUserName}: '\n", |
| 676 | " f'(logontype={x.LogonType}) '\n", |
| 677 | " f'timerange={x.TimeGenerated} - {x.LastEventTime} '\n", |
| 678 | " f'count={x.ClusterSize}'),\n", |
| 679 | " axis=1).values.tolist()\n", |
| 680 | "\n", |
| 681 | "def get_selected_logon_cluster(selected_item):\n", |
| 682 | " acct_match = re.search(r'(?P<acct>[^:]+):\\s+\\(logontype=(?P<l_type>[^)]+)', selected_item)\n", |
| 683 | " if acct_match:\n", |
| 684 | " acct = acct_match['acct']\n", |
| 685 | " l_type = int(acct_match['l_type'])\n", |
| 686 | " return host_logons.query('TargetUserName == @acct and LogonType == @l_type')\n", |
| 687 | "\n", |
| 688 | "logon_list_regex = r'''\n", |
| 689 | "(?P<acct>[^:]+):\\s+\n", |
| 690 | "\\(logontype=(?P<logon_type>[^)]+)\\)\\s+\n", |
| 691 | "\\(timestamp=(?P<time>[^)]+)\\)\\s+\n", |
| 692 | "logonid=(?P<logonid>[0-9a-fx)]+)\n", |
| 693 | "'''\n", |
| 694 | "\n", |
| 695 | "def get_selected_logon(selected_item):\n", |
| 696 | " acct_match = re.search(logon_list_regex, selected_item, re.VERBOSE)\n", |
| 697 | " if acct_match:\n", |
| 698 | " acct = acct_match['acct']\n", |
| 699 | " logon_type = int(acct_match['logon_type'])\n", |
| 700 | " time_stamp = pd.to_datetime(acct_match['time'])\n", |
| 701 | " logon_id = acct_match['logonid']\n", |
| 702 | " return host_logons.query('TargetUserName == @acct and LogonType == @logon_type'\n", |
| 703 | " ' and TargetLogonId == @logon_id')\n", |
| 704 | " \n", |
| 705 | "logon_wgt = mas.SelectString(description='Select logon cluster to examine', \n", |
| 706 | " item_list=items, height='200px', width='100%', auto_display=True)" |
| 707 | ] |
| 708 | }, |
| 709 | { |
| 710 | "cell_type": "code", |
| 711 | "execution_count": null, |
| 712 | "metadata": {}, |
| 713 | "outputs": [], |
| 714 | "source": [ |
| 715 | "# Calculate time range based on the logons from previous section\n", |
| 716 | "selected_logon_cluster = get_selected_logon_cluster(logon_wgt.value)\n", |
| 717 | "\n", |
| 718 | "if len(selected_logon_cluster) > 20:\n", |
| 719 | " display(Markdown('<h3><p style=\"color:red\">Warning: the selected '\n", |
| 720 | " 'cluster has a high number of logons.</p></h1><br>'\n", |
| 721 | " 'Processes for these logons may be very slow '\n", |
| 722 | " 'to retrieve and result in high memory usage.<br>'\n", |
| 723 | " 'You may wish to narrow the time range and sample'\n", |
| 724 | " 'the data before running the query for the full range.'))\n", |
| 725 | " \n", |
| 726 | "logon_time = selected_logon_cluster['TimeGenerated'].min()\n", |
| 727 | "last_logon_time = selected_logon_cluster['TimeGenerated'].max()\n", |
| 728 | "time_diff = int((last_logon_time - logon_time).total_seconds() / (60 * 60) + 2)\n", |
| 729 | "\n", |
| 730 | "# set the origin time to the time of our alert\n", |
| 731 | "proc_query_times = mas.QueryTime(units='hours', origin_time=logon_time,\n", |
| 732 | " before=1, after=time_diff, max_before=60, max_after=120)\n", |
| 733 | "proc_query_times.display()" |
| 734 | ] |
| 735 | }, |
| 736 | { |
| 737 | "cell_type": "code", |
| 738 | "execution_count": null, |
| 739 | "metadata": {}, |
| 740 | "outputs": [], |
| 741 | "source": [ |
| 742 | "from msticpy.sectools.eventcluster import dbcluster_events, add_process_features\n", |
| 743 | "print('Getting process events...', end='')\n", |
| 744 | "processes_on_host = qry.list_processes(provs=[proc_query_times], **params_dict)\n", |
| 745 | "print('done')\n", |
| 746 | "print('Clustering...', end='')\n", |
| 747 | "feature_procs = add_process_features(input_frame=processes_on_host,\n", |
| 748 | " path_separator=params_dict['path_separator'])\n", |
| 749 | "\n", |
| 750 | "feature_procs['accountNum'] = feature_procs.apply(lambda x: _string_score(x.Account), axis=1)\n", |
| 751 | "# you might need to play around with the max_cluster_distance parameter.\n", |
| 752 | "# decreasing this gives more clusters.\n", |
| 753 | "(clus_events, dbcluster, x_data) = dbcluster_events(data=feature_procs,\n", |
| 754 | " cluster_columns=['commandlineTokensFull', \n", |
| 755 | " 'pathScore',\n", |
| 756 | " 'accountNum',\n", |
| 757 | " 'isSystemSession'],\n", |
| 758 | " max_cluster_distance=0.0001)\n", |
| 759 | "print('done')\n", |
| 760 | "print('Number of input events:', len(feature_procs))\n", |
| 761 | "print('Number of clustered events:', len(clus_events))\n", |
| 762 | "\n", |
| 763 | "def view_logon_sess(x=''):\n", |
| 764 | " global selected_logon\n", |
| 765 | " selected_logon = get_selected_logon(x)\n", |
| 766 | " logonId = selected_logon['TargetLogonId'].iloc[0]\n", |
| 767 | " sess_procs = (processes_on_host.query('TargetLogonId == @logonId | SubjectLogonId == @logonId')\n", |
| 768 | " [['NewProcessName', 'CommandLine', 'TargetLogonId']]\n", |
| 769 | " .drop_duplicates())\n", |
| 770 | " display(sess_procs)\n", |
| 771 | "\n", |
| 772 | "selected_logon_cluster = get_selected_logon_cluster(logon_wgt.value)\n", |
| 773 | " \n", |
| 774 | "selected_tgt_logon = selected_logon_cluster['TargetUserName'].iat[0]\n", |
| 775 | "system_logon = selected_tgt_logon.lower() == 'system' or selected_tgt_logon.endswith('$')\n", |
| 776 | "\n", |
| 777 | "if system_logon:\n", |
| 778 | " \n", |
| 779 | " display(Markdown('<h3><p style=\"color:red\">Warning: the selected '\n", |
| 780 | " 'account name appears to be a system account.</p></h1><br>'\n", |
| 781 | " '<i>It is difficult to accurately associate processes '\n", |
| 782 | " 'with the specific logon sessions.<br>'\n", |
| 783 | " 'Showing clustered events for entire time selection.'))\n", |
| 784 | " display(clus_events.sort_values('TimeGenerated')[['TimeGenerated', 'LastEventTime',\n", |
| 785 | " 'NewProcessName', 'CommandLine', \n", |
| 786 | " 'ClusterSize', 'commandlineTokensFull',\n", |
| 787 | " 'pathScore', 'isSystemSession']])\n", |
| 788 | "\n", |
| 789 | "# Display a pick list for logon instances\n", |
| 790 | "items = (host_logons.query('TargetUserName == @selected_tgt_logon')\n", |
| 791 | " .sort_values('TimeGenerated')\n", |
| 792 | " .apply(lambda x: (f'{x.TargetUserName}: '\n", |
| 793 | " f'(logontype={x.LogonType}) '\n", |
| 794 | " f'(timestamp={x.TimeGenerated}) '\n", |
| 795 | " f'logonid={x.TargetLogonId}'),\n", |
| 796 | " axis=1).values.tolist())\n", |
| 797 | "sess_w = widgets.Select(options=items, description='Select logon instance to examine', **WIDGET_DEFAULTS)\n", |
| 798 | "\n", |
| 799 | "widgets.interactive(view_logon_sess, x=sess_w)" |
| 800 | ] |
| 801 | }, |
| 802 | { |
| 803 | "cell_type": "markdown", |
| 804 | "metadata": {}, |
| 805 | "source": [ |
| 806 | "<a id='cmdlineiocs'></a>[Contents](#toc)\n", |
| 807 | "# Check for IOCs in Commandline for current session\n", |
| 808 | "This section looks for Indicators of Compromise (IoC) within the data sets passed to it.\n", |
| 809 | "\n", |
| 810 | "The first section looks at the commandlines for the processes in the selected session. It also looks for base64 encoded strings within the data - this is a common way of hiding attacker intent. It attempts to decode any strings that look like base64. Additionally, if the base64 decode operation returns any items that look like a base64 encoded string or file, a gzipped binary sequence, a zipped or tar archive, it will attempt to extract the contents before searching for potentially interesting IoC observables within the decoded data." |
| 811 | ] |
| 812 | }, |
| 813 | { |
| 814 | "cell_type": "code", |
| 815 | "execution_count": null, |
| 816 | "metadata": {}, |
| 817 | "outputs": [], |
| 818 | "source": [ |
| 819 | "if not system_logon:\n", |
| 820 | " logonId = selected_logon['TargetLogonId'].iloc[0]\n", |
| 821 | " sess_procs = (processes_on_host.query('TargetLogonId == @logonId | SubjectLogonId == @logonId'))\n", |
| 822 | "else:\n", |
| 823 | " sess_procs = clus_events\n", |
| 824 | " \n", |
| 825 | "ioc_extractor = sectools.IoCExtract()\n", |
| 826 | "os_family = host_entity.OSType if host_entity.OSType else 'Windows'\n", |
| 827 | "\n", |
| 828 | "ioc_df = ioc_extractor.extract(data=sess_procs, \n", |
| 829 | " columns=['CommandLine'],\n", |
| 830 | " os_family=os_family,\n", |
| 831 | " ioc_types=['ipv4', 'ipv6', 'dns', 'url',\n", |
| 832 | " 'md5_hash', 'sha1_hash', 'sha256_hash'])\n", |
| 833 | "if len(ioc_df):\n", |
| 834 | " display(Markdown(\"### IoC patterns found in process set.\"))\n", |
| 835 | " display(ioc_df)\n", |
| 836 | "else:\n", |
| 837 | " display(Markdown(\"### No IoC patterns found in process tree.\"))" |
| 838 | ] |
| 839 | }, |
| 840 | { |
| 841 | "cell_type": "markdown", |
| 842 | "metadata": {}, |
| 843 | "source": [ |
| 844 | "### If any Base64 encoded strings, decode and search for IoCs in the results.\n", |
| 845 | "For simple strings the Base64 decoded output is straightforward. However for nested encodings this can get a little complex and difficult to represent in a tabular format.\n", |
| 846 | "\n", |
| 847 | "**Columns**\n", |
| 848 | " - reference - The index of the row item in dotted notation in depth.seq pairs (e.g. 1.2.2.3 would be the 3 item at depth 3 that is a child of the 2nd item found at depth 1). This may not always be an accurate notation - it is mainly use to allow you to associate an individual row with the reference value contained in the full_decoded_string column of the topmost item).\n", |
| 849 | " - original_string - the original string before decoding.\n", |
| 850 | " - file_name - filename, if any (only if this is an item in zip or tar file).\n", |
| 851 | " - file_type - a guess at the file type (this is currently elementary and only includes a few file types).\n", |
| 852 | " - input_bytes - the decoded bytes as a Python bytes string.\n", |
| 853 | " - decoded_string - the decoded string if it can be decoded as a UTF-8 or UTF-16 string. Note: binary sequences may often successfully decode as UTF-16 strings but, in these cases, the decodings are meaningless.\n", |
| 854 | " - encoding_type - encoding type (UTF-8 or UTF-16) if a decoding was possible, otherwise 'binary'.\n", |
| 855 | " - file_hashes - collection of file hashes for any decoded item.\n", |
| 856 | " - md5 - md5 hash as a separate column.\n", |
| 857 | " - sha1 - sha1 hash as a separate column.\n", |
| 858 | " - sha256 - sha256 hash as a separate column.\n", |
| 859 | " - printable_bytes - printable version of input_bytes as a string of \\xNN values\n", |
| 860 | " - src_index - the index of the row in the input dataframe from which the data came.\n", |
| 861 | " - full_decoded_string - the full decoded string with any decoded replacements. This is only really useful for top-level items, since nested items will only show the 'full' string representing the child fragment." |
| 862 | ] |
| 863 | }, |
| 864 | { |
| 865 | "cell_type": "code", |
| 866 | "execution_count": null, |
| 867 | "metadata": { |
| 868 | "scrolled": true |
| 869 | }, |
| 870 | "outputs": [], |
| 871 | "source": [ |
| 872 | "dec_df = sectools.b64.unpack_items(data=sess_procs, column='CommandLine')\n", |
| 873 | "if len(dec_df) > 0:\n", |
| 874 | " display(HTML(\"<h3>Decoded base 64 command lines</h3>\"))\n", |
| 875 | " display(HTML(\"Decoded values and hashes of decoded values shown below.\"))\n", |
| 876 | " display(HTML('Warning - some binary patterns may be decodable as unicode strings. '\n", |
| 877 | " 'In these cases you should ignore the \"decoded_string\" column '\n", |
| 878 | " 'and treat the encoded item as a binary - using the \"printable_bytes\" '\n", |
| 879 | " 'column or treat the decoded_string as a binary (bytes) value.'))\n", |
| 880 | " \n", |
| 881 | " display(dec_df[['full_decoded_string', 'decoded_string', 'original_string', 'printable_bytes', 'file_hashes']])\n", |
| 882 | "\n", |
| 883 | " ioc_dec_df = ioc_extractor.extract(data=dec_df, columns=['full_decoded_string'])\n", |
| 884 | " if len(ioc_dec_df):\n", |
| 885 | " display(HTML(\"<h3>IoC patterns found in events with base64 decoded data</h3>\"))\n", |
| 886 | " display(ioc_dec_df)\n", |
| 887 | " ioc_df = ioc_df.append(ioc_dec_df ,ignore_index=True)\n", |
| 888 | "else:\n", |
| 889 | " print(\"No base64 encodings found.\")" |
| 890 | ] |
| 891 | }, |
| 892 | { |
| 893 | "cell_type": "markdown", |
| 894 | "metadata": { |
| 895 | "hidden": true |
| 896 | }, |
| 897 | "source": [ |
| 898 | "<a id='virustotallookup'></a>[Contents](#toc)\n", |
| 899 | "## Virus Total Lookup\n", |
| 900 | "This section uses the popular Virus Total service to check any recovered IoCs against VTs database.\n", |
| 901 | "\n", |
| 902 | "To use this you need an API key from virus total, which you can obtain here: https://www.virustotal.com/.\n", |
| 903 | "\n", |
| 904 | "Note that VT throttles requests for free API keys to 4/minute. If you are unable to process the entire data set, try splitting it and submitting smaller chunks.\n", |
| 905 | "\n", |
| 906 | "**Things to note:**\n", |
| 907 | "- Virus Total lookups include file hashes, domains, IP addresses and URLs.\n", |
| 908 | "- The returned data is slightly different depending on the input type\n", |
| 909 | "- The VTLookup class tries to screen input data to prevent pointless lookups. E.g.:\n", |
| 910 | " - Only public IP Addresses will be submitted (no loopback, private address space, etc.)\n", |
| 911 | " - URLs with only local (unqualified) host parts will not be submitted.\n", |
| 912 | " - Domain names that are unqualified will not be submitted.\n", |
| 913 | " - Hash-like strings (e.g 'AAAAAAAAAAAAAAAAAA') that do not appear to have enough entropy to be a hash will not be submitted.\n", |
| 914 | "\n", |
| 915 | "**Output Columns**\n", |
| 916 | " - Observable - The IoC observable submitted\n", |
| 917 | " - IoCType - the IoC type\n", |
| 918 | " - Status - the status of the submission request\n", |
| 919 | " - ResponseCode - the VT response code\n", |
| 920 | " - RawResponse - the entire raw json response\n", |
| 921 | " - Resource - VT Resource\n", |
| 922 | " - SourceIndex - The index of the Observable in the source DataFrame. You can use this to rejoin to your original data.\n", |
| 923 | " - VerboseMsg - VT Verbose Message\n", |
| 924 | " - ScanId - VT Scan ID if any\n", |
| 925 | " - Permalink - VT Permanent URL describing the resource\n", |
| 926 | " - Positives - If this is not zero, it indicates the number of malicious reports that VT holds for this observable.\n", |
| 927 | " - MD5 - The MD5 hash, if any\n", |
| 928 | " - SHA1 - The MD5 hash, if any\n", |
| 929 | " - SHA256 - The MD5 hash, if any\n", |
| 930 | " - ResolvedDomains - In the case of IP Addresses, this contains a list of all domains that resolve to this IP address\n", |
| 931 | " - ResolvedIPs - In the case Domains, this contains a list of all IP addresses resolved from the domain.\n", |
| 932 | " - DetectedUrls - Any malicious URLs associated with the observable." |
| 933 | ] |
| 934 | }, |
| 935 | { |
| 936 | "cell_type": "code", |
| 937 | "execution_count": null, |
| 938 | "metadata": {}, |
| 939 | "outputs": [], |
| 940 | "source": [ |
| 941 | "vt_key = mas.GetEnvironmentKey(env_var='VT_API_KEY',\n", |
| 942 | " help_str='To obtain an API key sign up here https://www.virustotal.com/',\n", |
| 943 | " prompt='Virus Total API key:')\n", |
| 944 | "vt_key.display()" |
| 945 | ] |
| 946 | }, |
| 947 | { |
| 948 | "cell_type": "code", |
| 949 | "execution_count": null, |
| 950 | "metadata": { |
| 951 | "scrolled": false |
| 952 | }, |
| 953 | "outputs": [], |
| 954 | "source": [ |
| 955 | "if vt_key.value and ioc_df is not None and not ioc_df.empty:\n", |
| 956 | " vt_lookup = sectools.VTLookup(vt_key.value, verbosity=2)\n", |
| 957 | "\n", |
| 958 | " print(f'{len(ioc_df)} items in input frame')\n", |
| 959 | " supported_counts = {}\n", |
| 960 | " for ioc_type in vt_lookup.supported_ioc_types:\n", |
| 961 | " supported_counts[ioc_type] = len(ioc_df[ioc_df['IoCType'] == ioc_type])\n", |
| 962 | " print('Items in each category to be submitted to VirusTotal')\n", |
| 963 | " print('(Note: items have pre-filtering to remove obvious erroneous '\n", |
| 964 | " 'data and false positives, such as private IPaddresses)')\n", |
| 965 | " print(supported_counts)\n", |
| 966 | " print('-' * 80)\n", |
| 967 | " vt_results = vt_lookup.lookup_iocs(data=ioc_df, type_col='IoCType', src_col='Observable')\n", |
| 968 | " display(vt_results)" |
| 969 | ] |
| 970 | }, |
| 971 | { |
| 972 | "cell_type": "markdown", |
| 973 | "metadata": {}, |
| 974 | "source": [ |
| 975 | "<a id='comms_to_other_hosts'></a>[Contents](#toc)\n", |
| 976 | "# Network Check Communications with Other Hosts" |
| 977 | ] |
| 978 | }, |
| 979 | { |
| 980 | "cell_type": "code", |
| 981 | "execution_count": null, |
| 982 | "metadata": { |
| 983 | "scrolled": false |
| 984 | }, |
| 985 | "outputs": [], |
| 986 | "source": [ |
| 987 | "# Azure Network Analytics Base Query\n", |
| 988 | "\n", |
| 989 | " \n", |
| 990 | "az_net_analytics_query =r'''\n", |
| 991 | "AzureNetworkAnalytics_CL \n", |
| 992 | "| where SubType_s == 'FlowLog'\n", |
| 993 | "| where FlowStartTime_t >= datetime({start})\n", |
| 994 | "| where FlowEndTime_t <= datetime({end})\n", |
| 995 | "| project TenantId, TimeGenerated, \n", |
| 996 | " FlowStartTime = FlowStartTime_t, \n", |
| 997 | " FlowEndTime = FlowEndTime_t, \n", |
| 998 | " FlowIntervalEndTime = FlowIntervalEndTime_t, \n", |
| 999 | " FlowType = FlowType_s,\n", |
| 1000 | " ResourceGroup = split(VM_s, '/')[0],\n", |
| 1001 | " VMName = split(VM_s, '/')[1],\n", |
| 1002 | " VMIPAddress = VMIP_s, \n", |
| 1003 | " PublicIPs = extractall(@\"([\\d\\.]+)[|\\d]+\", dynamic([1]), PublicIPs_s),\n", |
| 1004 | " SrcIP = SrcIP_s,\n", |
| 1005 | " DestIP = DestIP_s,\n", |
| 1006 | " ExtIP = iif(FlowDirection_s == 'I', SrcIP_s, DestIP_s),\n", |
| 1007 | " L4Protocol = L4Protocol_s, \n", |
| 1008 | " L7Protocol = L7Protocol_s, \n", |
| 1009 | " DestPort = DestPort_d, \n", |
| 1010 | " FlowDirection = FlowDirection_s,\n", |
| 1011 | " AllowedOutFlows = AllowedOutFlows_d, \n", |
| 1012 | " AllowedInFlows = AllowedInFlows_d,\n", |
| 1013 | " DeniedInFlows = DeniedInFlows_d, \n", |
| 1014 | " DeniedOutFlows = DeniedOutFlows_d,\n", |
| 1015 | " RemoteRegion = AzureRegion_s,\n", |
| 1016 | " VMRegion = Region_s\n", |
| 1017 | "| extend AllExtIPs = iif(isempty(PublicIPs), pack_array(ExtIP), \n", |
| 1018 | " iif(isempty(ExtIP), PublicIPs, array_concat(PublicIPs, pack_array(ExtIP)))\n", |
| 1019 | " )\n", |
| 1020 | "| project-away ExtIP\n", |
| 1021 | "| mvexpand AllExtIPs\n", |
| 1022 | "{where_clause}\n", |
| 1023 | "'''\n", |
| 1024 | "\n", |
| 1025 | "ip_q_times = mas.QueryTime(label='Set time bounds for network queries',\n", |
| 1026 | " units='hour', max_before=48, before=10, after=5, \n", |
| 1027 | " max_after=24)\n", |
| 1028 | "ip_q_times.display()" |
| 1029 | ] |
| 1030 | }, |
| 1031 | { |
| 1032 | "cell_type": "markdown", |
| 1033 | "metadata": {}, |
| 1034 | "source": [ |
| 1035 | "### Query Flows by Host IP Addresses" |
| 1036 | ] |
| 1037 | }, |
| 1038 | { |
| 1039 | "cell_type": "code", |
| 1040 | "execution_count": null, |
| 1041 | "metadata": {}, |
| 1042 | "outputs": [], |
| 1043 | "source": [ |
| 1044 | "if 'AzureNetworkAnalytics_CL' not in table_index:\n", |
| 1045 | " print('No network flow data available.')\n", |
| 1046 | " az_net_comms_df = None\n", |
| 1047 | "else:\n", |
| 1048 | " all_host_ips = host_entity.private_ips + host_entity.public_ips + [host_entity.IPAddress]\n", |
| 1049 | " host_ips = {'\\'{}\\''.format(i.Address) for i in all_host_ips}\n", |
| 1050 | " host_ip_list = ','.join(host_ips)\n", |
| 1051 | "\n", |
| 1052 | " if not host_ip_list:\n", |
| 1053 | " raise ValueError('No IP Addresses for host. Cannot lookup network data')\n", |
| 1054 | "\n", |
| 1055 | " az_ip_where = f'''\n", |
| 1056 | " | where (VMIPAddress in ({host_ip_list}) \n", |
| 1057 | " or SrcIP in ({host_ip_list}) \n", |
| 1058 | " or DestIP in ({host_ip_list}) \n", |
| 1059 | " ) and \n", |
| 1060 | " (AllowedOutFlows > 0 or AllowedInFlows > 0)'''\n", |
| 1061 | " print('getting data...')\n", |
| 1062 | " az_net_query_byip = az_net_analytics_query.format(where_clause=az_ip_where,\n", |
| 1063 | " start = ip_q_times.start,\n", |
| 1064 | " end = ip_q_times.end)\n", |
| 1065 | "\n", |
| 1066 | " net_default_cols = ['FlowStartTime', 'FlowEndTime', 'VMName', 'VMIPAddress', \n", |
| 1067 | " 'PublicIPs', 'SrcIP', 'DestIP', 'L4Protocol', 'L7Protocol',\n", |
| 1068 | " 'DestPort', 'FlowDirection', 'AllowedOutFlows', \n", |
| 1069 | " 'AllowedInFlows']\n", |
| 1070 | "\n", |
| 1071 | " %kql -query az_net_query_byip\n", |
| 1072 | " az_net_comms_df = _kql_raw_result_.to_dataframe()\n", |
| 1073 | " az_net_comms_df[net_default_cols]\n", |
| 1074 | "\n", |
| 1075 | " if len(az_net_comms_df) > 0:\n", |
| 1076 | " import warnings\n", |
| 1077 | "\n", |
| 1078 | " with warnings.catch_warnings():\n", |
| 1079 | " warnings.simplefilter(\"ignore\")\n", |
| 1080 | "\n", |
| 1081 | " az_net_comms_df['TotalAllowedFlows'] = az_net_comms_df['AllowedOutFlows'] + az_net_comms_df['AllowedInFlows']\n", |
| 1082 | " sns.catplot(x=\"L7Protocol\", y=\"TotalAllowedFlows\", col=\"FlowDirection\", data=az_net_comms_df)\n", |
| 1083 | " sns.relplot(x=\"FlowStartTime\", y=\"TotalAllowedFlows\", \n", |
| 1084 | " col=\"FlowDirection\", kind=\"line\", \n", |
| 1085 | " hue=\"L7Protocol\", data=az_net_comms_df).set_xticklabels(rotation=50)\n", |
| 1086 | "\n", |
| 1087 | " nbdisp.display_timeline(data=az_net_comms_df.query('AllowedOutFlows > 0'),\n", |
| 1088 | " overlay_data=az_net_comms_df.query('AllowedInFlows > 0'),\n", |
| 1089 | " title='Network Flows (out=blue, in=green)',\n", |
| 1090 | " time_column='FlowStartTime',\n", |
| 1091 | " source_columns=['FlowType', 'AllExtIPs', 'L7Protocol', 'FlowDirection'],\n", |
| 1092 | " height=300)\n", |
| 1093 | " else:\n", |
| 1094 | " print('No network data for specified time range.')" |
| 1095 | ] |
| 1096 | }, |
| 1097 | { |
| 1098 | "cell_type": "markdown", |
| 1099 | "metadata": {}, |
| 1100 | "source": [ |
| 1101 | "### Flow Summary" |
| 1102 | ] |
| 1103 | }, |
| 1104 | { |
| 1105 | "cell_type": "code", |
| 1106 | "execution_count": null, |
| 1107 | "metadata": {}, |
| 1108 | "outputs": [], |
| 1109 | "source": [ |
| 1110 | "if az_net_comms_df is not None and not az_net_comms_df.empty:\n", |
| 1111 | " cm = sns.light_palette(\"green\", as_cmap=True)\n", |
| 1112 | "\n", |
| 1113 | " cols = ['VMName', 'VMIPAddress', 'PublicIPs', 'SrcIP', 'DestIP', 'L4Protocol',\n", |
| 1114 | " 'L7Protocol', 'DestPort', 'FlowDirection', 'AllExtIPs', 'TotalAllowedFlows']\n", |
| 1115 | " flow_index = az_net_comms_df[cols].copy()\n", |
| 1116 | " def get_source_ip(row):\n", |
| 1117 | " if row.FlowDirection == 'O':\n", |
| 1118 | " return row.VMIPAddress if row.VMIPAddress else row.SrcIP\n", |
| 1119 | " else:\n", |
| 1120 | " return row.AllExtIPs if row.AllExtIPs else row.DestIP\n", |
| 1121 | "\n", |
| 1122 | " def get_dest_ip(row):\n", |
| 1123 | " if row.FlowDirection == 'O':\n", |
| 1124 | " return row.AllExtIPs if row.AllExtIPs else row.DestIP\n", |
| 1125 | " else:\n", |
| 1126 | " return row.VMIPAddress if row.VMIPAddress else row.SrcIP\n", |
| 1127 | "\n", |
| 1128 | " flow_index['source'] = flow_index.apply(get_source_ip, axis=1)\n", |
| 1129 | " flow_index['target'] = flow_index.apply(get_dest_ip, axis=1)\n", |
| 1130 | " flow_index['value'] = flow_index['L7Protocol']\n", |
| 1131 | "\n", |
| 1132 | " with warnings.catch_warnings():\n", |
| 1133 | " warnings.simplefilter(\"ignore\")\n", |
| 1134 | " display(flow_index[['source', 'target', 'value', 'L7Protocol', \n", |
| 1135 | " 'FlowDirection', 'TotalAllowedFlows']]\n", |
| 1136 | " .groupby(['source', 'target', 'value', 'L7Protocol', \n", |
| 1137 | " 'FlowDirection'])\n", |
| 1138 | " .sum().unstack().style.background_gradient(cmap=cm))" |
| 1139 | ] |
| 1140 | }, |
| 1141 | { |
| 1142 | "cell_type": "markdown", |
| 1143 | "metadata": {}, |
| 1144 | "source": [ |
| 1145 | "## GeoIP Map of External IPs" |
| 1146 | ] |
| 1147 | }, |
| 1148 | { |
| 1149 | "cell_type": "code", |
| 1150 | "execution_count": null, |
| 1151 | "metadata": { |
| 1152 | "scrolled": true |
| 1153 | }, |
| 1154 | "outputs": [], |
| 1155 | "source": [ |
| 1156 | "from msticpy.nbtools.foliummap import FoliumMap\n", |
| 1157 | "folium_map = FoliumMap()\n", |
| 1158 | "\n", |
| 1159 | "if az_net_comms_df is None or az_net_comms_df.empty:\n", |
| 1160 | " print('No network flow data available.')\n", |
| 1161 | "else: \n", |
| 1162 | " ip_locs_in = set()\n", |
| 1163 | " ip_locs_out = set()\n", |
| 1164 | " for _, row in az_net_comms_df.iterrows():\n", |
| 1165 | " ip = row.AllExtIPs\n", |
| 1166 | "\n", |
| 1167 | " if ip in ip_locs_in or ip in ip_locs_out or not ip:\n", |
| 1168 | " continue\n", |
| 1169 | " ip_entity = mas.IpAddress(Address=ip)\n", |
| 1170 | " iplocation.lookup_ip(ip_entity=ip_entity)\n", |
| 1171 | " if not ip_entity.Location:\n", |
| 1172 | " continue\n", |
| 1173 | " ip_entity.AdditionalData['protocol'] = row.L7Protocol\n", |
| 1174 | " if row.FlowDirection == 'I':\n", |
| 1175 | " ip_locs_in.add(ip_entity)\n", |
| 1176 | " else:\n", |
| 1177 | " ip_locs_out.add(ip_entity)\n", |
| 1178 | "\n", |
| 1179 | " display(HTML('<h3>External IP Addresses communicating with host</h3>'))\n", |
| 1180 | " display(HTML('Numbered circles indicate multiple items - click to expand'))\n", |
| 1181 | " display(HTML('Location markers: Blue = outbound, Purple = inbound, Green = Host'))\n", |
| 1182 | "\n", |
| 1183 | " icon_props = {'color': 'green'}\n", |
| 1184 | " folium_map.add_ip_cluster(ip_entities=host_entity.public_ips,\n", |
| 1185 | " **icon_props)\n", |
| 1186 | " icon_props = {'color': 'blue'}\n", |
| 1187 | " folium_map.add_ip_cluster(ip_entities=ip_locs_out,\n", |
| 1188 | " **icon_props)\n", |
| 1189 | " icon_props = {'color': 'purple'}\n", |
| 1190 | " folium_map.add_ip_cluster(ip_entities=ip_locs_in,\n", |
| 1191 | " **icon_props)\n", |
| 1192 | "\n", |
| 1193 | " display(folium_map.folium_map)\n", |
| 1194 | " display(Markdown('<p style=\"color:red\">Warning: the folium mapping library '\n", |
| 1195 | " 'does not display correctly in some browsers.</p><br>'\n", |
| 1196 | " 'If you see a blank image please retry with a different browser.'))" |
| 1197 | ] |
| 1198 | }, |
| 1199 | { |
| 1200 | "cell_type": "markdown", |
| 1201 | "metadata": { |
| 1202 | "hidden": true |
| 1203 | }, |
| 1204 | "source": [ |
| 1205 | "<a id='appendices'></a>[Contents](#toc)\n", |
| 1206 | "# Appendices" |
| 1207 | ] |
| 1208 | }, |
| 1209 | { |
| 1210 | "cell_type": "markdown", |
| 1211 | "metadata": {}, |
| 1212 | "source": [ |
| 1213 | "## Available DataFrames" |
| 1214 | ] |
| 1215 | }, |
| 1216 | { |
| 1217 | "cell_type": "code", |
| 1218 | "execution_count": null, |
| 1219 | "metadata": { |
| 1220 | "scrolled": true |
| 1221 | }, |
| 1222 | "outputs": [], |
| 1223 | "source": [ |
| 1224 | "print('List of current DataFrames in Notebook')\n", |
| 1225 | "print('-' * 50)\n", |
| 1226 | "current_vars = list(locals().keys())\n", |
| 1227 | "for var_name in current_vars:\n", |
| 1228 | " if isinstance(locals()[var_name], pd.DataFrame) and not var_name.startswith('_'):\n", |
| 1229 | " print(var_name)" |
| 1230 | ] |
| 1231 | }, |
| 1232 | { |
| 1233 | "cell_type": "markdown", |
| 1234 | "metadata": { |
| 1235 | "heading_collapsed": true, |
| 1236 | "tags": [ |
| 1237 | "todo" |
| 1238 | ] |
| 1239 | }, |
| 1240 | "source": [ |
| 1241 | "## Saving Data to Excel\n", |
| 1242 | "To save the contents of a pandas DataFrame to an Excel spreadsheet\n", |
| 1243 | "use the following syntax\n", |
| 1244 | "```\n", |
| 1245 | "writer = pd.ExcelWriter('myWorksheet.xlsx')\n", |
| 1246 | "my_data_frame.to_excel(writer,'Sheet1')\n", |
| 1247 | "writer.save()\n", |
| 1248 | "```" |
| 1249 | ] |
| 1250 | } |
| 1251 | ], |
| 1252 | "metadata": { |
| 1253 | "hide_input": false, |
| 1254 | "kernelspec": { |
| 1255 | "name": "python36", |
| 1256 | "display_name": "Python 3.6", |
| 1257 | "language": "python" |
| 1258 | }, |
| 1259 | "language_info": { |
| 1260 | "codemirror_mode": { |
| 1261 | "name": "ipython", |
| 1262 | "version": 3 |
| 1263 | }, |
| 1264 | "file_extension": ".py", |
| 1265 | "mimetype": "text/x-python", |
| 1266 | "name": "python", |
| 1267 | "nbconvert_exporter": "python", |
| 1268 | "pygments_lexer": "ipython3", |
| 1269 | "version": "3.7.1" |
| 1270 | }, |
| 1271 | "toc": { |
| 1272 | "base_numbering": 1, |
| 1273 | "nav_menu": { |
| 1274 | "height": "318.996px", |
| 1275 | "width": "320.994px" |
| 1276 | }, |
| 1277 | "number_sections": false, |
| 1278 | "sideBar": true, |
| 1279 | "skip_h1_title": false, |
| 1280 | "title_cell": "Table of Contents2", |
| 1281 | "title_sidebar": "Contents", |
| 1282 | "toc_cell": false, |
| 1283 | "toc_position": { |
| 1284 | "height": "calc(100% - 180px)", |
| 1285 | "left": "10px", |
| 1286 | "top": "150px", |
| 1287 | "width": "165px" |
| 1288 | }, |
| 1289 | "toc_section_display": true, |
| 1290 | "toc_window_display": false |
| 1291 | }, |
| 1292 | "varInspector": { |
| 1293 | "cols": { |
| 1294 | "lenName": 16, |
| 1295 | "lenType": 16, |
| 1296 | "lenVar": 40 |
| 1297 | }, |
| 1298 | "kernels_config": { |
| 1299 | "python": { |
| 1300 | "delete_cmd_postfix": "", |
| 1301 | "delete_cmd_prefix": "del ", |
| 1302 | "library": "var_list.py", |
| 1303 | "varRefreshCmd": "print(var_dic_list())" |
| 1304 | }, |
| 1305 | "r": { |
| 1306 | "delete_cmd_postfix": ") ", |
| 1307 | "delete_cmd_prefix": "rm(", |
| 1308 | "library": "var_list.r", |
| 1309 | "varRefreshCmd": "cat(var_dic_list()) " |
| 1310 | } |
| 1311 | }, |
| 1312 | "position": { |
| 1313 | "height": "406.193px", |
| 1314 | "left": "1468.4px", |
| 1315 | "right": "20px", |
| 1316 | "top": "120px", |
| 1317 | "width": "456.572px" |
| 1318 | }, |
| 1319 | "types_to_exclude": [ |
| 1320 | "module", |
| 1321 | "function", |
| 1322 | "builtin_function_or_method", |
| 1323 | "instance", |
| 1324 | "_Feature" |
| 1325 | ], |
| 1326 | "window_display": false |
| 1327 | } |
| 1328 | }, |
| 1329 | "nbformat": 4, |
| 1330 | "nbformat_minor": 2 |
| 1331 | } |