Public/Invoke-NSPM365AuditTriage.ps1
|
function Invoke-NSPM365AuditTriage { <# .SYNOPSIS Triages a Unified Audit Log export for a suspected mailbox compromise and writes a severity-sorted Findings.csv with a timeline, IP table, sign-ins, mail read and inbox rules. .DESCRIPTION Reads one or more Purview audit CSV exports (Audit > Search > Export) and writes to -OutputPath: Findings.csv severity-sorted, the same columns as Invoke-NSPM365IocHunt's Timeline.csv every record, oldest first, with client IP, trust, country, ISP and the raw AuditData IPs.csv one row per client IP: trust, count, users, operations, geolocation and ASN Summary.csv one row per operation: count, untrusted count, first and last time SignIns.csv UserLoggedIn and UserLoginFailed in one timeline, with user agent and logon error AccessedMail.csv MailItemsAccessed, one row per message read (Get-NSPM365AccessedMessage) InboxRules.csv inbox rule and forwarding changes, flagged (Get-NSPM365InboxRuleAudit) An address is trusted when it matches -TrustedIP or its ASN is in -TrustedAsn; every other address is untrusted. Records with no client IP are not attributed either way. Microsoft's ASN (8075) is never trusted on its own, because attackers run from Azure too; it is only exempt from the hosting-provider finding. Pass -TrustedAsn 8075 to trust it. Findings: Critical a suspicious inbox rule or forwarding change from an untrusted address High a suspicious inbox rule from anywhere; a successful sign-in from a hosting, proxy or VPN address or from outside -ExpectedCountry; a mailbox folder synced (downloaded) from an untrusted address; an app consent from an untrusted address Medium app consents; messages read, sent or hard-deleted from an untrusted address; any activity from a hosting/proxy address or outside -ExpectedCountry; rule changes from an untrusted address Low soft deletes from an untrusted address; failed sign-ins from hosting/proxy addresses Untrusted activity is grouped per user and address, so one attacker session is one finding. Client IPs are looked up on ip-api.com (Resolve-NSPIpInfo) unless -SkipIpLookup: plain HTTP, non-commercial licence. Without the lookup the hosting and country findings are skipped. .PARAMETER Path One or more Purview audit CSV exports. .PARAMETER TrustedIP Known-good addresses (office egress, the user's home ISP): exact, CIDR, or a prefix ending in '*'. .PARAMETER TrustedAsn Known-good ASNs ('7018' or 'AS7018'), e.g. the user's home ISP or mobile carrier. .PARAMETER ExpectedCountry Two-letter country codes the users sign in from (US). Activity elsewhere is a finding. .PARAMETER InternalDomain The tenant's own domains, so internal forwarding isn't flagged. .PARAMETER OutputPath Report folder. Defaults to .\AuditTriage_<timestamp>. .PARAMETER SkipIpLookup Don't send the client IPs to ip-api.com. .PARAMETER PassThru Also return the findings. .EXAMPLE Invoke-NSPM365AuditTriage -Path .\audit.csv -TrustedIP 198.51.100.10 -ExpectedCountry US -InternalDomain contoso.com .EXAMPLE Invoke-NSPM365AuditTriage -Path .\jdoe-1.csv, .\jdoe-2.csv -TrustedIP 198.51.100.0/24 -TrustedAsn 7018, 22394 -OutputPath .\jdoe-triage .NOTES Export with every operation for the user (no operation filter) and a window starting well before the suspected compromise. MailItemsAccessed needs audit logging enabled for the mailbox; E3 tenants have it since 2024. #> [CmdletBinding()] param( [Parameter(Mandatory, Position = 0)] [string[]]$Path, [string[]]$TrustedIP = @(), [string[]]$TrustedAsn = @(), [string[]]$ExpectedCountry = @(), [string[]]$InternalDomain = @(), [string]$OutputPath, [switch]$SkipIpLookup, [switch]$PassThru ) $ErrorActionPreference = 'Stop' if (-not $OutputPath) { $OutputPath = Join-Path (Get-Location).ProviderPath ('AuditTriage_{0}' -f (Get-Date -Format 'yyyyMMdd_HHmmss')) } if (-not (Test-Path -LiteralPath $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } $OutputPath = (Resolve-Path -LiteralPath $OutputPath).ProviderPath $label = @($Path | ForEach-Object { [IO.Path]::GetFileNameWithoutExtension($_) }) -join '+' $hunt = @{ TenantLabel = $label; Findings = New-Object System.Collections.Generic.List[object] } $records = Read-NSPAuditCsv -Path $Path $views = @(foreach ($record in $records) { ConvertTo-NSPAuditView $record }) $span = if ($views.Count) { "$($views[0].TimeUtc) -> $($views[-1].TimeUtc)" } else { 'no records' } Write-Host "Audit triage: $($views.Count) record(s) from $(@($Path).Count) file(s), $span. Output: $OutputPath" -ForegroundColor Cyan if (-not $TrustedIP -and -not $TrustedAsn) { Write-Warning 'No -TrustedIP or -TrustedAsn: every address counts as untrusted, so expect noise.' } if ($ExpectedCountry -and $SkipIpLookup) { Write-Warning '-ExpectedCountry needs the IP lookup; it is ignored with -SkipIpLookup.' } # Per-address facts, computed once. $ips = @($views | ForEach-Object ClientIP | Where-Object { $_ } | Sort-Object -Unique) $info = @{} if (-not $SkipIpLookup -and $ips.Count) { Write-Host "Looking up $($ips.Count) address(es) on ip-api.com..." -ForegroundColor DarkGray foreach ($result in (Resolve-NSPIpInfo -IPAddress $ips)) { $info[$result.IP] = $result } } $asns = @($TrustedAsn | ForEach-Object { ([string]$_) -replace '^(?i)AS', '' }) $countries = @($ExpectedCountry | ForEach-Object { $_.ToUpperInvariant() }) $trust = @{ '' = '' } $risky = @{} $foreign = @{} foreach ($ip in $ips) { $i = $info[$ip] $trusted = (Test-NSPIpInRange -Ip $ip -Range $TrustedIP) -or ($i -and $i.Asn -and $asns -contains $i.Asn) $trust[$ip] = if ($trusted) { 'Trusted' } else { 'Untrusted' } $risky[$ip] = [bool]($i -and ($i.Hosting -or $i.Proxy) -and $i.Asn -ne $script:NSPMicrosoftAsn) $foreign[$ip] = [bool]($i -and $countries.Count -and $i.CountryCode -and $countries -notcontains $i.CountryCode) } $where = { param($ip) $i = $info[$ip] if (-not $i -or $i.Status -ne 'success') { return $ip } "$ip ($(@($i.City, $i.CountryCode, $i.Isp, $(if ($i.Asn) { "AS$($i.Asn)" }) | Where-Object { $_ }) -join ', '))" } $untrusted = { param($ip) $ip -and $trust[$ip] -eq 'Untrusted' } $geoColumns = { param($ip) $i = $info[$ip] [ordered]@{ CountryCode = if ($i) { $i.CountryCode } else { '' }; City = if ($i) { $i.City } else { '' }; Isp = if ($i) { $i.Isp } else { '' }; Asn = if ($i) { $i.Asn } else { '' }; Hosting = if ($i) { $i.Hosting } else { '' }; Proxy = if ($i) { $i.Proxy } else { '' } } } $write = { param($Rows, $Name) @($Rows) | Export-Csv -LiteralPath (Join-Path $OutputPath $Name) -NoTypeInformation -Encoding UTF8 } # One finding per user and address for untrusted activity of one kind. $grouped = { param($Rows, $Severity, $Check, $What) foreach ($g in ($Rows | Group-Object UserId, ClientIP)) { $first = $g.Group[0] $times = @($g.Group | ForEach-Object TimeUtc | Sort-Object) Add-NSPHuntFinding -Hunt $hunt -Severity $Severity -Check $Check -Object $first.UserId -Time $times[0] ` -Detail ("{0} {1} from {2}, {3} -> {4}." -f $g.Count, $What, (& $where $first.ClientIP), $times[0], $times[-1]) } } # Timeline, IPs, Summary. & $write @(foreach ($v in $views) { $row = [ordered]@{ TimeUtc = $v.TimeUtc; Operation = $v.Operation; Workload = $v.Workload; UserId = $v.UserId; ClientIP = $v.ClientIP; Trust = $trust[$v.ClientIP] } $geo = & $geoColumns $v.ClientIP foreach ($key in 'CountryCode', 'Isp', 'Asn') { $row[$key] = $geo[$key] } $row.ResultStatus = $v.ResultStatus $row.RecordId = $v.RecordId $row.AuditData = if ($v.Record.PSObject.Properties['_Raw'] -and $v.Record._Raw.AuditData) { [string]$v.Record._Raw.AuditData } else { ConvertTo-Json -InputObject $v.Record.AuditData -Depth 10 -Compress } [pscustomobject]$row }) 'Timeline.csv' $ipRows = @(foreach ($g in ($views | Where-Object ClientIP | Group-Object ClientIP)) { $i = $info[$g.Name] $times = @($g.Group | ForEach-Object TimeUtc | Sort-Object) [pscustomobject]@{ ClientIP = $g.Name Trust = $trust[$g.Name] Count = $g.Count FirstUtc = $times[0] LastUtc = $times[-1] Users = @($g.Group | ForEach-Object UserId | Sort-Object -Unique) -join '; ' Operations = @($g.Group | ForEach-Object Operation | Sort-Object -Unique) -join '; ' CountryCode = if ($i) { $i.CountryCode } else { '' } Country = if ($i) { $i.Country } else { '' } Region = if ($i) { $i.Region } else { '' } City = if ($i) { $i.City } else { '' } Isp = if ($i) { $i.Isp } else { '' } Org = if ($i) { $i.Org } else { '' } Asn = if ($i) { $i.Asn } else { '' } AsName = if ($i) { $i.AsName } else { '' } Hosting = if ($i) { $i.Hosting } else { '' } Proxy = if ($i) { $i.Proxy } else { '' } Mobile = if ($i) { $i.Mobile } else { '' } } }) & $write @($ipRows | Sort-Object @{ Expression = { $_.Trust -eq 'Trusted' } }, @{ Expression = 'Count'; Descending = $true }) 'IPs.csv' & $write @(foreach ($g in ($views | Group-Object Operation | Sort-Object Count -Descending)) { $times = @($g.Group | ForEach-Object TimeUtc | Sort-Object) $bad = @($g.Group | Where-Object { & $untrusted $_.ClientIP }) [pscustomobject]@{ Operation = $g.Name Count = $g.Count UntrustedCount = $bad.Count FirstUtc = $times[0] LastUtc = $times[-1] Users = @($g.Group | ForEach-Object UserId | Sort-Object -Unique) -join '; ' UntrustedIPs = @($bad | ForEach-Object ClientIP | Sort-Object -Unique) -join '; ' } }) 'Summary.csv' # Sign-ins. $signIns = @(foreach ($v in ($views | Where-Object { $_.Operation -in 'UserLoggedIn', 'UserLoginFailed' })) { $audit = $v.Record.AuditData $extended = if ($audit.PSObject.Properties['ExtendedProperties']) { $audit.ExtendedProperties } else { @() } $row = [ordered]@{ TimeUtc = $v.TimeUtc; Operation = $v.Operation; UserId = $v.UserId; ClientIP = $v.ClientIP; Trust = $trust[$v.ClientIP] } $geo = & $geoColumns $v.ClientIP foreach ($key in $geo.Keys) { $row[$key] = $geo[$key] } $row.ResultStatus = $v.ResultStatus $row.LogonError = if ($audit.PSObject.Properties['LogonError']) { [string]$audit.LogonError } else { '' } $row.ErrorNumber = if ($audit.PSObject.Properties['ErrorNumber']) { [string]$audit.ErrorNumber } else { '' } $row.UserAgent = [string](Get-NSPAuditNameValue $extended 'UserAgent') $row.RequestType = [string](Get-NSPAuditNameValue $extended 'RequestType') $row.ApplicationId = if ($audit.PSObject.Properties['ApplicationId']) { [string]$audit.ApplicationId } else { '' } $row.RecordId = $v.RecordId [pscustomobject]$row }) & $write $signIns 'SignIns.csv' $success = @($signIns | Where-Object { $_.Operation -eq 'UserLoggedIn' -and (& $untrusted $_.ClientIP) }) & $grouped @($success | Where-Object { $risky[$_.ClientIP] }) 'High' 'Sign-in' 'successful sign-in(s) from a hosting/proxy/VPN address' & $grouped @($success | Where-Object { -not $risky[$_.ClientIP] -and $foreign[$_.ClientIP] }) 'High' 'Sign-in' 'successful sign-in(s) from outside the expected countries' & $grouped @($signIns | Where-Object { $_.Operation -eq 'UserLoginFailed' -and (& $untrusted $_.ClientIP) -and $risky[$_.ClientIP] }) 'Low' 'Sign-in' 'failed sign-in(s) from a hosting/proxy/VPN address' # Mail read. $accessed = @(Get-NSPM365AccessedMessage -InputObject $records | Select-Object *, @{ Name = 'Trust'; Expression = { $trust[$_.ClientIP] } }) & $write $accessed 'AccessedMail.csv' $badAccess = @($accessed | Where-Object { & $untrusted $_.ClientIP }) & $grouped @($badAccess | Where-Object MailAccessType -EQ 'Sync') 'High' 'Mail read' 'folder sync(s) - whole folders downloaded -' & $grouped @($badAccess | Where-Object MailAccessType -NE 'Sync') 'Medium' 'Mail read' 'message(s) opened' # Inbox rules and forwarding. $rules = @(Get-NSPM365InboxRuleAudit -InputObject $records -InternalDomain $InternalDomain | Select-Object *, @{ Name = 'Trust'; Expression = { $trust[$_.ClientIP] } }) & $write $rules 'InboxRules.csv' foreach ($rule in $rules) { $fromBad = & $untrusted $rule.ClientIP $object = if ($rule.RuleName) { "$($rule.UserId): $($rule.RuleName)" } else { "$($rule.UserId): $($rule.Operation)" } $detail = "$($rule.Operation) from $(& $where $rule.ClientIP)" if ($rule.Flags) { $detail += ". Flags: $($rule.Flags)" } if ($rule.ForwardTo) { $detail += ". Forwards to: $($rule.ForwardTo)" } if ($rule.MoveToFolder) { $detail += ". Moves to: $($rule.MoveToFolder)" } if ($rule.Suspicious) { Add-NSPHuntFinding -Hunt $hunt -Severity $(if ($fromBad) { 'Critical' } else { 'High' }) -Check 'Inbox rule' -Object $object -Detail $detail -Time $rule.TimeUtc } elseif ($fromBad -and $rule.Operation -notin 'Remove-InboxRule', 'Disable-InboxRule') { Add-NSPHuntFinding -Hunt $hunt -Severity Medium -Check 'Inbox rule' -Object $object -Detail "$detail. Not flagged, but changed from an untrusted address." -Time $rule.TimeUtc } } # App consents, sends, deletes. foreach ($v in ($views | Where-Object { $script:NSPConsentOperations -contains $_.Operation })) { $target = if ($v.Record.AuditData.PSObject.Properties['ObjectId']) { [string]$v.Record.AuditData.ObjectId } else { '' } Add-NSPHuntFinding -Hunt $hunt -Severity $(if (& $untrusted $v.ClientIP) { 'High' } else { 'Medium' }) -Check 'App consent' -Object $v.UserId -Time $v.TimeUtc ` -Detail "$($v.Operation.TrimEnd('.')) $target from $(& $where $v.ClientIP). Check the app's permissions and publisher." } $badViews = @($views | Where-Object { & $untrusted $_.ClientIP }) & $grouped @($badViews | Where-Object { $_.Operation -in 'Send', 'SendAs', 'SendOnBehalf' }) 'Medium' 'Mail sent' 'message(s) sent' & $grouped @($badViews | Where-Object Operation -EQ 'HardDelete') 'Medium' 'Mail deleted' 'hard delete(s)' & $grouped @($badViews | Where-Object { $_.Operation -in 'SoftDelete', 'MoveToDeletedItems' }) 'Low' 'Mail deleted' 'soft delete(s) / move(s) to Deleted Items' # Activity from hosting providers or unexpected countries, per address. foreach ($row in ($ipRows | Where-Object { $_.Trust -eq 'Untrusted' -and ($risky[$_.ClientIP] -or $foreign[$_.ClientIP]) })) { $why = @($(if ($risky[$row.ClientIP]) { 'a hosting/proxy/VPN address' }), $(if ($foreign[$row.ClientIP]) { 'outside the expected countries' }) | Where-Object { $_ }) -join ', ' Add-NSPHuntFinding -Hunt $hunt -Severity Medium -Check 'Address' -Object (& $where $row.ClientIP) -Time $row.FirstUtc ` -Detail "$($row.Count) record(s) from $why, $($row.FirstUtc) -> $($row.LastUtc). Users: $($row.Users). Operations: $($row.Operations)." } $sorted = @($hunt.Findings | Sort-Object @{ Expression = { $script:NSPHuntSeverityOrder[$_.Severity] } }, TimeUtc) & $write $sorted 'Findings.csv' Write-Host '' Write-Host '=== Summary ===' -ForegroundColor Cyan [pscustomobject]@{ Records = $views.Count Addresses = $ips.Count Untrusted = @($ips | Where-Object { $trust[$_] -eq 'Untrusted' }).Count Critical = @($sorted | Where-Object Severity -EQ 'Critical').Count High = @($sorted | Where-Object Severity -EQ 'High').Count Medium = @($sorted | Where-Object Severity -EQ 'Medium').Count Low = @($sorted | Where-Object Severity -EQ 'Low').Count } | Format-Table -AutoSize | Out-Host Write-Host "Findings: $(Join-Path $OutputPath 'Findings.csv')" if ($PassThru) { $sorted } } |