<jupyter_start><jupyter_text>Requirements: First we need to install the required powershell modules<jupyter_code>Install-Module Az.OperationalInsights,Az.SecurityInsights,MCAS<jupyter_output><empty_output><jupyter_text>Configuration and Setup- This section is for setting up / verifying your configuration file    - Connecting to your Azure Account    - Setting the HTML Header for our charts.  Verifying Your Configuration File is present and valid<jupyter_code>##Get your configuration file settings
$nbcontentpath = "config.json"
if(!(test-path $nbcontentpath)){
    write-host "INFO: Your configuration path ($nbcontentpath) could not be located."
    write-host "INFO: Attempting to build the file path explicitly.  If this continues to be a problem, run 'dir' within the cell to find the current working directory and update the `$nbcontentpath variable accordingly."    
    $configpath = read-host "Enter the top level folder where the config is stored:"
    $nbcontentpath = "$configpath"+ "config.json"
}

##Path fix in case you picked up the cookie cutter configuration file (if you cloned repo from GitHub in terminal)
if(test-path $nbcontentpath){
    $content = gc $nbcontentpath | ?{$_ -match "cookiecutter"}
    if($content.Length -gt 0) {
        $nbcontentpath = "..\" + $nbcontentpath
    }    
}

try {
    $nbconfigcontent = Get-Content $nbcontentpath -ErrorAction Stop    
}
catch {
    write-host "ERROR: Your configuration path ($nbcontentpath) could not be located. Please fix before continuing further."    
}

##Set variables you will use throughout the notebook
$tenantid =  ($nbconfigcontent | ConvertFrom-Json).tenant_id
$subscriptionid = ($nbconfigcontent | ConvertFrom-Json).subscription_id
$resourcegroup = ($nbconfigcontent | ConvertFrom-Json).resource_group
$workspacename = ($nbconfigcontent | ConvertFrom-Json).workspace_name
$workspaceid = ($nbconfigcontent | ConvertFrom-Json).workspace_id

Write-Host "subscriptionid: " -NoNewline 
Write-Host $subscriptionid -ForegroundColor "DarkRed"
Write-Host "tenantid: " -NoNewline 
write-host $tenantid  -ForegroundColor "DarkRed"
Write-Host "workspaceid: " -NoNewline 
Write-host $workspaceid -Foregroundcolor "DarkRed"
Write-Host "workspacename: " -NoNewline 
Write-host $workspacename -ForegroundColor "DarkRed"
Write-Host "resourcegroup: " -NoNewline 
Write-host $resourcegroup -ForegroundColor "DarkRed"<jupyter_output><empty_output><jupyter_text>Azure Account Authentication- The output from the cell below will provide you with a **url** and **code** to authorize access to your Azure Account.<jupyter_code>Connect-AzAccount -UseDeviceAuthentication
Select-AzSubscription -SubscriptionId $subscriptionid | Set-AzContext<jupyter_output><empty_output><jupyter_text>Validate that the correct workspace is selected- Anywhere in the notebook you see three dots you can click them to expand the cell referenced in the notes<jupyter_code>##Configure the Log Analytics workspace
$Workspace = $null
$workspaces = Get-AzOperationalInsightsWorkspace -ResourceGroupName $resourcegroup
if($workspaces.Length -gt 1) {
    Write-Host "INFO: Multiple workspaces detected." 
    foreach($wksp in $workspaces){
        if($wksp.Name -eq $workspacename)    {
          $Workspace = $wksp
        }        
    }    
}
else {
     $Workspace = $workspaces 
}
Write-Host "INFO: Ensure that the workspace -- {"$Workspace.Name"} is the intended target workspace before continuing to the next cell."   
$Workspace<jupyter_output><empty_output><jupyter_text>Set HTML Chart Properties- This sets the HTML header for the tables we will be using for our output in the notebook.- If you don't run the cell below your tables will not be formatted correctly.- You will not see any output from the cell below when you run it.<jupyter_code>$HtmlHead = '
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
    body {
        background-color: white;
        font-family:      "Calibri";
    }

    table {
        border-width:     1px;
        border-style:     solid;
        border-color:     black;
        border-collapse:  collapse;
        width:            100%;
        table-layout: fixed;
        margin-left: auto;
        margin-right: auto;
    }

    th {
        border-width:     1px;
        padding:          5px;
        border-style:     solid;
        border-color:     black;
        background-color: #98C6F3;
    }

    td {
        border-width:     1px;
        padding:          5px;
        border-style:     solid;
        border-color:     black;
        background-color: White;
        word-break: break-all;
    }

    tr {
        text-align:       left;
    }
</style>'<jupyter_output><empty_output><jupyter_text>Incidents In the series of cells below we will walk through investigating an "**Unfamiliar Signin Properties**" Incident. * First we will get a list of Incidents for this type within the lookback period * Next we will choose a specific account to investigate * From there we check things like     * Signins (Location / Ip Addresses / User Agent / Client Application)    * Alerts Linked to Account    * Activities from MCAS (Optional / Not parsed)  Get Unfamiliar Incidents / Alerts- The function below is used to get unfamiliar incidents and alerts- You will not see any output from the cell.<jupyter_code>function Get-UnfamiliarAlerts {

    [CmdletBinding()]
    param (
    
        [Parameter(Mandatory=$true,
        HelpMessage="Enter a TimeFrame in the format of: IntegerCharacter. Example: 1d - Meaning 1 day.")]
        [string]
        $TimeFrame,
        
        [Parameter(Mandatory=$false,
        HelpMessage="Enter a UserPrincipalName to filter alerts on. Example: <EMAIL>@<EMAIL>")]
        [string]
        $UserPrincipalName
    )

        if ($UserPrincipalName) {
                $UserFilter = "| where AlertName == 'Unfamiliar sign-in properties' and CompromisedEntity has `"$UserPrincipalName`" "
            }
        else {
                $UserFilter = "//NoUserFilteredOn"
            }
        
$IncidentQuery = @"
    let Incidents = SecurityIncident
    | where TimeGenerated > ago($TimeFrame)
    | where Title == 'Unfamiliar sign-in properties'
    | extend SystemAlertId = tostring(AlertIds[0])
    | summarize arg_max(TimeGenerated, *) by IncidentNumber
    //| where Status == 'Closed'
    | project IncidentNumber, SystemAlertId;

    SecurityAlert
    | where TimeGenerated > ago($TimeFrame)
    $UserFilter
    | summarize arg_max(TimeGenerated,*), count() by SystemAlertId
    | extend IPAddress = tostring(parse_json(ExtendedProperties).["Client IP Address"])
    | extend ClientLocation = tostring(parse_json(ExtendedProperties).["Client Location"])
    //| extend DetectionSubcategory = tostring(parse_json(ExtendedProperties).["Detection Subcategory"])
    | extend RequestId = tostring(parse_json(ExtendedProperties).["Request Id"])
    | extend TenantLoginSource = tostring(parse_json(ExtendedProperties).["Tenant Login Source"])
    | extend UserName = tostring(parse_json(ExtendedProperties).["User Name"])
    | extend AadUserId = tostring(parse_json(Entities)[0].AadUserId)
    | join kind= inner (Incidents) on SystemAlertId
    | sort by TimeGenerated desc
    | extend Date = format_datetime(TimeGenerated, 'MM-dd-yy [hh:mm tt]')
    | project Date, AlertType, UserName , UserPrincipalName = Comp<PASSWORD>, IPAddress, ClientLocation, RequestId, SystemAlertId, IncidentNumber, AadUserId
"@

        Write-Verbose -Message "Query to run:`n$IncidentQuery"
        
        $IncidentResults = @(Invoke-AzOperationalInsightsQuery -Workspace $Workspace -Query $IncidentQuery)
        
        Write-Verbose -Message "If query does not return results, stop."
        if (($IncidentResults.results).count -gt 0){
        
        write-verbose -Message "Getting query results, converting to html, and outputting to 'IncidentsWithAlerts.html'"
        $IncidentResults.results | ConvertTo-Html -Head $HtmlHead | out-file "IncidentsWithAlerts.html"
        
        foreach($Result in $IncidentResults.results){
                [PsCustomObject]@{
                UserPrincipalName = $Result.UserPrincipalName;
                IPAddress = $Result.IPAddress;
                IncidentNumber = $Result.IncidentNumber;
                }
            }
        }
        
        else {
        Write-Output "No results found. You may skip the next cell as no file was created containing the results."
        }
        
        

}<jupyter_output><empty_output><jupyter_text>Query Unfamliar Alerts- The below cell is where we actually use the function to retrieve our Incidents / Alerts- Note the 'TimeFrame' and adjust it accordingly    - m = minute, h = hour, d = day- Once run the cell will display the UPNs of the users in the found incidents / alerts, the IPAddress, and incident number- The cell immediately after it will show a more detailed look at the incidents and alerts.<jupyter_code>Get-UnfamiliarAlerts -TimeFrame '1d'<jupyter_output><empty_output><jupyter_text>Show Results as Table- Uses an iframe to show the results as a formatted table<jupyter_code>#!html
<iframe src = "IncidentsWithAlerts.html" width=100% height="400px" frameborder="2px"></iframe><jupyter_output><empty_output><jupyter_text>Get User Sign-ins- This function retrieves user sign-in events.- Run the cell below (You will not see any output from it, this just sets up the function to use.)<jupyter_code>function Get-UserSignins {

    [CmdletBinding()]
    param (
    
        [Parameter(Mandatory=$true,
        HelpMessage="Enter a TimeFrame in the format of: IntegerCharacter. Example: 1d - Meaning 1 day.")]
        [string]
        $TimeFrame,
        
        [Parameter(Mandatory=$true,
        HelpMessage="Enter a UserPrincipalName to filter alerts on. Example: <EMAIL>@<EMAIL>")]
        [string]
        $UserPrincipalName
    )
        
$SigninsQuery = @"
SigninLogs
| where TimeGenerated > ago($TimeFrame)
| where UserPrincipalName has '$UserPrincipalName'
| extend Browser = tostring(DeviceDetail.browser) 
| extend City = tostring(LocationDetails.city) 
| extend Country = tostring(LocationDetails.countryOrRegion) 
| extend State = tostring(LocationDetails.state) 
| extend Date = format_datetime(TimeGenerated, 'MM-dd-yy [hh:mm tt]')
| extend Succeeded = tostring(parse_json(AuthenticationDetails)[0].succeeded)
| extend Succeeded = iff(Succeeded == 'true','Succeeded','Failed')
| extend IsInteractive = iff(IsInteractive == 'true',"Interactive","Non-Interactive")
| sort by TimeGenerated desc
| project Date, UserPrincipalName, IPAddress, IsInteractive, Succeeded, Browser, App = AppDisplayName, UserAgent, Country, State, City, RiskState
"@


        Write-Verbose -Message "Query to run:`n$SigninsQuery"
        $SigninQuery = @(Invoke-AzOperationalInsightsQuery -Workspace $workspace -Query $SigninsQuery)
        
        Write-Verbose -Message "If query does not return results, stop."
        if (($SigninQuery.results).count -gt 0){
        
        write-verbose -Message "Getting query results, converting to html, and outputting to 'Signins.html'"
        $SigninQuery.results | ConvertTo-Html -Head $HtmlHead | Out-File "Signins.html"
        
        foreach($Result in $SigninQuery.results){
                [PsCustomObject]@{
                UserPrincipalName = $Result.UserPrincipalName;
                IPAddress = $Result.IPAddress;
                AadUserId = $Result.IsInteractive;
                Succeeded = $Result.Succeeded;
                App = $Result.App;
                Location = @($Result.Country,",",$Result.State,",",$Result.City);
                RiskState = $Result.RiskState;
                }
            }
        }
        
        else {

        Write-Output "No results found. You may skip the next cell as no file was created containing the results." 
        }
}

## Sign-in Logs<jupyter_output><empty_output><jupyter_text>Query Sign-ing Logs- The cell below queries the sign-in logs within the specified time range (note that it requires specifying a UserPrincipalName.)- You can remove the '' from the code below to filter on a specific UserPrincipalName in the cell, or you can run it and specify it at run time.> Remember, you can expand a cell by clicking the three dots.<jupyter_code>$Signins = Get-UserSignins -TimeFrame '1d' # -UserPrincipalName "<EMAIL>"
$Signins<jupyter_output><empty_output><jupyter_text>Visualize Sign-ins as a table- You can adjust the Height according to your needs - Example height=50% or height="500px"<jupyter_code>#!html
<iframe src = "signins.html" width=100% height="350px" frameborder="0"></iframe><jupyter_output><empty_output><jupyter_text>Get Related Alerts- The below cell sets up our function for finding any related alerts for the UPN provided.- You will not see any output from the cell below when you run it (but make sure to run it!)<jupyter_code>function Get-RelatedAlerts {

    [CmdletBinding()]
    param (
    
        [Parameter(Mandatory=$true,
        HelpMessage="Enter a TimeFrame in the format of: IntegerCharacter. Example: 1d - Meaning 1 day.")]
        [string]
        $TimeFrame,
        
        [Parameter(Mandatory=$true,
        HelpMessage="Enter a UserPrincipalName to filter alerts on. Example: <EMAIL>")]
        [string]
        $UserPrincipalName
    )
        
$RelatedAlertsQuery = @"
let Incidents = SecurityIncident
| where TimeGenerated > ago($TimeFrame)
| extend SystemAlertId = tostring(AlertIds[0])
| summarize arg_max(TimeGenerated, *) by IncidentNumber
| project IncidentNumber, SystemAlertId;

SecurityAlert
| where TimeGenerated > ago($Timeframe)
| where AlertName !has "Unfamiliar sign-in properties"
| extend Name = tostring(parse_json(Entities)[0].Name)
| extend UPNSuffix = tostring(parse_json(Entities)[0].UPNSuffix)
| extend UserPrincipalName = iff(CompromisedEntity != '',CompromisedEntity,strcat(Name,"@",UPNSuffix))
| where UserPrincipalName has "$UserPrincipalName"
| summarize arg_max(TimeGenerated,*), count() by SystemAlertId
| join kind= inner (Incidents) on SystemAlertId
| extend Date = format_datetime(TimeGenerated, 'MM-dd-yy [hh:mm tt]')
| sort by TimeGenerated desc
| project Date, AlertName, IncidentNumber
"@

        Write-Verbose -Message "Query to run:`n$RelatedAlertsQuery"
        $RelatedAlerts = @(Invoke-AzOperationalInsightsQuery -Workspace $workspace -Query $RelatedAlertsQuery)
        
        Write-Verbose -Message "If query does not return results, stop."
        if (($RelatedAlerts.results).count -gt 0){
        
        write-verbose -Message "Getting query results, converting to html, and outputting to 'Signins.html'"
        $RelatedAlerts.results | ConvertTo-Html -Head $HtmlHead | Out-File "RelatedAlerts.html"
        
        foreach($Result in $RelatedAlerts.results){
                [PsCustomObject]@{
                Date = $Result.Date;
                AlertName = $Result.AlertName;
                IncidentNumber = $Result.IncidentNumber;
                }
            }
        }
        
        else {
        Write-Output "No results found. You may skip the next cell as no file was created containing the results."
        }
}<jupyter_output><empty_output><jupyter_text>Call function to get Related Alerts- Default Timerange is 7 days, you can change it inline or remove it from the cell and specify it at runtime.<jupyter_code>$RelatedAlerts = Get-RelatedAlerts -TimeFrame '7d'
$RelatedAlerts | Out-Null<jupyter_output><empty_output><jupyter_text>Visualize results as a table in iframe<jupyter_code>#!html
<iframe src = "RelatedAlerts.html" width=100% height="350px" frameborder="0"></iframe><jupyter_output><empty_output><jupyter_text>MCAS credential - To authenticate to MCAS you will need two things (detailed below).1. The **username** = your cloud app security url. Which is found by navigating to [Cloud App Security](https://portal.cloudappsecurity.com) and clicking the **?** in the top right, then clicking **about**. A window will popup and at the bottom of it will be your url (See Below Image).2. Then you will need to generate an api token for Cloud App Security (If you haven't already done so)    * For this you would select the gear icon in the top right of the MCAS portal    * Click **Security Extensions**    * Then click **Add Token**3. After gathering the required information run the cell below which will prompt for     * Username (MCAS url)          >**Note:** Omit the "Https://" from the url!        * Password (API Token)If you wish to save this information for future use (so you don't have to enter it every time), instead run the cell **Save MCAS Credential**.<jupyter_code>Get-MCASCredential<jupyter_output><empty_output><jupyter_text>Save MCAS Credential- This cell will prompt for your MCAS url, API key, and export them to **MCAS.credential**- This allows you to run the **Import MCAS Credential** cell instead of having to enter your information every time.<jupyter_code># Only need to run this initially
Get-MCASCredential -PassThru | Export-CliXml MCAS.credential -Force<jupyter_output><empty_output><jupyter_text>Import MCAS Credential**- Imports your saved MCAS credential from the **MCAS.credential** file.<jupyter_code>$CASCredential = Import-CliXml 'MCAS.credential'

Write-Host "Validate that the correct url shows in the UserName field" -ForegroundColor Blue

$CASCredential<jupyter_output><empty_output><jupyter_text>Get MCAS User Activity- The cell below is a powershell function that allows you to retrieve user activity from MCAS- Run the cell below (which will have no output), then run the cell after it (where you will see your output).<jupyter_code>function Get-MCASUserActivity {

    [CmdletBinding()]
    param (
    
        [Parameter(Mandatory=$true,
        HelpMessage="Enter a TimeFrame as a number. Example 1 - for 1 day.")]
        [int32]
        $TimeFrame,
        
        [Parameter(Mandatory=$true,
        HelpMessage="Enter a UserPrincipalName to filter events on. Example: <EMAIL>")]
        [string]
        $UserPrincipalName
    )
    
        Write-Verbose -Message "Getting $UserPrincipalName's MCAS Data. Latest 100 events."
        $MCASActivity = @((Get-MCASActivity -DaysAgo $TimeFrame -User $UserPrincipalName  -ResultSetSize 100).rawDataJson)
         
        Write-Verbose -Message "If query does not return results, stop."
        if (($MCASActivity).count -gt 0){
        
      #  write-verbose -Message "Getting event results, converting to html, and outputting to 'MCASActivity.html'"
     #  $MCASActivity | ConvertTo-Html -Head $HtmlHead | Out-File "MCASActivity.html"
            
        $MCASActivity | Out-Host
            }
        
        else {
        Write-Output "No results found. You may skip the next cell as no file was created containing the results."
        }
}<jupyter_output><empty_output><jupyter_text>Get MCAS User Activity- Retrieves user activity from MCAS- Make sure to adjust the TimeFrame in the cell. The default is 7 (meaning 7 days).<jupyter_code>Get-MCASUserActivity -TimeFrame 7<jupyter_output><empty_output><jupyter_text>Incident Closure- The cell below allows you to close / classify an incident (or incidents).- Breakdown of what the cell does:    - Asks for you to input a comma or space delimited list of incidents    - Retrieves the incident information for each incident specified    - Ask if the incident is a True Positive (Enter Y/N)    - If Y, then classify as true positive, close, and optionally trigger a webhook to a logicapp with a json body containing the users UPN        - Note: The logic app part has been commented out. Removing the  from lines 12 and 21 to enable.    - If N, then ask to provide comments for incident, what to classify the incident as, and close the incident.    - If nothing is entered (or anything other than Y/N), do nothng.<jupyter_code>[string[]] $IncidentList = @()
$IncidentList = Read-host "Enter list of incidents to pull (Seperated by comma or space)"
$IncidentList = $IncidentList.split(',').split(' ')
$Incidents = Get-AzSentinelIncident -ResourceGroupName $resourcegroup -WorkspaceName $workspacename | Where-Object {$_.IncidentNumber -in $IncidentList}

foreach($Incident in $Incidents)
{
$IncidentResult = Read-Host "Based on the information gathered is incident "$Incident.IncidentNumber" a true positive? (Y/N)"
    if($IncidentResult -eq 'Y') 
{
    $Incident |  Update-AzSentinelIncident -Classification TruePositive -ClassificationReason SuspiciousActivity -Status Closed
<#    
    $UserPrincipalName = read-host "Enter the UPN to include in the body of the webhook request"
    $body = @"
    {
        "UserPrincipalName": "$UserPrincipalName"
    }
"@

Invoke-RestMethod -Uri "InsertLogicAppWebHookUri" -ContentType "application/json" -Body $body -Method Post
#>

}

    elseif($IncidentResult -eq 'N')
{    
        write-host "Choose an option 1-4"
        write-host "1 False Positive - incorrect alert logic"
        write-host "2 False Positive - inaccurate data"
        write-host "3 Undetermined"
        write-host "4 Benign Positive - suspicious but expected"
        Write-Host "Entering any other value or no value will result in no change to the incident."
        $Classification = Read-Host "Choose a classification 1-4"
        if ($Classification -in @("1","2","3","4")){
            New-AzSentinelIncidentComment -ResourceGroupName $resourcegroup -WorkspaceName $workspace.Name -Message (Read-Host "Enter comments to add to incident " $Incident.IncidentNumber) -IncidentId ($Incident.Name)
            Write-host "Adding comments to incident " $i.IncidentNumber"."
        if ($Classification -eq '1') {$Incident |  Update-AzSentinelIncident -Classification FalsePositive -ClassificationReason IncorrectAlertLogic -Status Closed}
        elseif ($Classification -eq '2') {$Incident |  Update-AzSentinelIncident -Classification FalsePositive -ClassificationReason InaccurateData  -Status Closed}
        elseif ($Classification -eq '3') {$Incident |  Update-AzSentinelIncident -Classification Undetermined -Status Closed}
        elseif ($Classification -eq '4') {$Incident |  Update-AzSentinelIncident -Classification BenignPositive -ClassificationReason SuspiciousButExpected -Status Closed}
        Write-Host "Closing Incident "$Incident.IncidentNumber"."
        }
        
        else {Write-Host "No change being made to incident."}
                 
}
    else
{
        Write-Host "Still investigating. Got it."
}
}<jupyter_output><empty_output><jupyter_text>Cleanup**The below cell removes all html files generated for tables generated from this notebook.**<jupyter_code>Remove-Item "RelatedAlerts.html","Signins.html","IncidentsWithAlerts.html","IncidentsToUpdate.html","MCASData.html" -Confirm<jupyter_output><empty_output>
{'fraction_non_alphanumeric': 0.09310586790632401, 'fraction_numerical': 0.004297868608016841, 'mean_word_length': 3.506521739130435, 'pattern_counts': {'":': 1, '<': 103, '<?xml version=': 0, '>': 110, 'https://': 1, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'chain_length': '21', 'dir': 'jupyter-structured-clean-dedup', 'id': '602922', 'license': 'MIT', 'n_tokens_mistral': 6633, 'n_tokens_neox': 6330, 'n_words': 2097, 'path': 'Notebooks/UnfamiliarSigninProperties.ipynb', 'repo_name': 'jostuffl/AzureSentinel_Stuff', 'size': '22850'}