Private/AuditHelpers.ps1
|
# Helpers shared by the Unified Audit Log, inbox rule, message trace and IP functions. function Import-NSPLogParse { # Loads NSP.LogParse on first use (ConvertFrom-NSPM365AuditCsv, Expand-NSPM365AuditRecord). Import-NSPToolkitModule -Name 'NSP.LogParse' -MinimumVersion '0.1.0' } function Read-NSPAuditCsv { # Parsed audit records from one or more Purview audit CSV exports, oldest first. param([Parameter(Mandatory)][string[]]$Path) Import-NSPLogParse $records = @(foreach ($file in $Path) { ConvertFrom-NSPM365AuditCsv -Path $file }) , @($records | Sort-Object { Get-NSPAuditTime $_ }) } function ConvertTo-NSPAuditIp { # Exchange writes ClientIP with a port ("203.0.113.5:51234", "[2001:db8::1]:443") and # sometimes IPv4-mapped IPv6; everything else writes a bare address. param([string]$Value) if (-not $Value) { return '' } $ip = $Value.Trim() if ($ip -match '^\[(?<ip>[^\]]+)\](:\d+)?$') { $ip = $Matches.ip } elseif ($ip -match '^(?<ip>\d{1,3}(\.\d{1,3}){3}):\d+$') { $ip = $Matches.ip } if ($ip -match '^::ffff:(?<ip>\d{1,3}(\.\d{1,3}){3})$') { $ip = $Matches.ip } $ip } function Get-NSPAuditClientIp { # The client address, whichever property this workload put it in. param($AuditData) if ($null -eq $AuditData) { return '' } foreach ($name in 'ClientIP', 'ClientIPAddress', 'ActorIpAddress', 'UserIp') { $property = $AuditData.PSObject.Properties[$name] if ($property -and $property.Value) { return (ConvertTo-NSPAuditIp ([string]$property.Value)) } } '' } function Get-NSPAuditTime { param($Record) $value = $null if ($Record.AuditData -and $Record.AuditData.PSObject.Properties['CreationTime']) { $value = $Record.AuditData.CreationTime } if (-not $value) { $value = $Record.CreationDate } Format-NSPHuntUtc $value } function Get-NSPAuditNameValue { # Value of one entry in the Name/Value arrays audit records use (Parameters, # OperationProperties, ExtendedProperties). param($List, [Parameter(Mandatory)][string]$Name) foreach ($entry in @($List)) { if ($entry -and $entry.PSObject.Properties['Name'] -and [string]$entry.Name -eq $Name) { return $entry.Value } } $null } function ConvertTo-NSPAuditView { # One normalized row per record: the columns every audit report starts from. param([Parameter(Mandatory)]$Record) $audit = $Record.AuditData $value = { param($n) if ($audit -and $audit.PSObject.Properties[$n]) { [string]$audit.$n } else { '' } } [pscustomobject]@{ TimeUtc = Get-NSPAuditTime $Record Operation = [string]$Record.Operation Workload = & $value 'Workload' UserId = if ($Record.UserId) { [string]$Record.UserId } else { & $value 'UserId' } ClientIP = Get-NSPAuditClientIp $audit ResultStatus = & $value 'ResultStatus' RecordId = [string]$Record.RecordId Record = $Record } } function Test-NSPIpInRange { # True when $Ip matches an entry: exact address, CIDR (IPv4 or IPv6), or a prefix ending in '*'. param([string]$Ip, [string[]]$Range) if (-not $Ip -or -not $Range) { return $false } $address = $null $parsed = [Net.IPAddress]::TryParse($Ip, [ref]$address) foreach ($entry in $Range) { if (-not $entry) { continue } if ($entry.Contains('/')) { $parts = $entry.Split('/', 2) $network = $null if (-not $parsed -or -not [Net.IPAddress]::TryParse($parts[0], [ref]$network) -or $network.AddressFamily -ne $address.AddressFamily) { continue } $a = $address.GetAddressBytes() $n = $network.GetAddressBytes() $bits = [int]$parts[1] $match = $true for ($i = 0; $i -lt $a.Length -and $bits -gt 0; $i++) { $mask = if ($bits -ge 8) { 255 } else { (0xFF -shl (8 - $bits)) -band 0xFF } if (($a[$i] -band $mask) -ne ($n[$i] -band $mask)) { $match = $false; break } $bits -= 8 } if ($match) { return $true } } elseif ($entry.EndsWith('*')) { if ($Ip.StartsWith($entry.TrimEnd('*'), [StringComparison]::OrdinalIgnoreCase)) { return $true } } elseif ($Ip -eq $entry) { return $true } } $false } function Get-NSPFileEncoding { # Import-Csv -Encoding value from the byte order mark. Historical message trace exports are # UTF-16 LE; the rest are UTF-8, with or without a BOM. param([Parameter(Mandatory)][string]$Path) $bytes = New-Object byte[] 3 $stream = [IO.File]::OpenRead($Path) try { $read = $stream.Read($bytes, 0, 3) } finally { $stream.Dispose() } if ($read -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) { return 'Unicode' } if ($read -ge 2 -and $bytes[0] -eq 0xFE -and $bytes[1] -eq 0xFF) { return 'BigEndianUnicode' } 'UTF8' } function ConvertTo-NSPRuleTarget { # Forwarding targets as addresses. Get-InboxRule gives '"Name" [SMTP:a@b.com]' or # '"Name" [EX:/o=...]' (an internal recipient); audit Parameters give 'a@b.com;c@d.com'. param($Value) foreach ($item in @($Value)) { if (-not $item) { continue } foreach ($part in ([string]$item -split ';')) { $text = $part.Trim() if (-not $text) { continue } if ($text -match 'SMTP:(?<a>[^\]\s]+)') { $Matches.a } elseif ($text -match '\[EX:') { 'EX:' + ($text -replace '^"([^"]*)".*$', '$1') } else { $text.Trim('"') } } } } function Get-NSPInboxRuleFlag { # Why an inbox rule (or mailbox forwarding) looks like attacker tradecraft. Empty when it doesn't. # 'ForwardsInternal' is informational: every target is internal. param( [string]$Name, [string]$MoveToFolder, [string[]]$ForwardTarget = @(), [bool]$Delete, [bool]$MarkAsRead, [string]$Conditions, [string[]]$InternalDomain = @() ) $flags = New-Object System.Collections.Generic.List[string] $targets = @($ForwardTarget | Where-Object { $_ }) if ($targets.Count) { $external = @($targets | Where-Object { if ($_ -like 'EX:*') { return $false } $domain = ($_ -split '@')[-1] -not ($_ -like '*@*' -and @($InternalDomain | Where-Object { $_ -and $domain -eq $_ }).Count) }) if ($external.Count) { $flags.Add('Forwards') } else { $flags.Add('ForwardsInternal') } } if ($Delete) { $flags.Add('Deletes') } if ($MarkAsRead) { $flags.Add('MarksRead') } if ($MoveToFolder) { $leaf = ($MoveToFolder -split '\\')[-1].Trim() if ($script:NSPRuleHidingFolders -contains $leaf) { $flags.Add("MovesTo:$leaf") } } # Mailbox forwarding has no name: only judge one that was passed. if ($PSBoundParameters.ContainsKey('Name') -and $Name -notmatch '[\p{L}\p{N}]{3}') { $flags.Add('OddName') } if ($Conditions -and $Conditions -match $script:NSPRuleKeywordPattern) { $flags.Add('TargetsKeywords') } $flags.ToArray() } # Folders attackers move mail into so the victim never sees replies. $script:NSPRuleHidingFolders = @('RSS Feeds', 'RSS Subscriptions', 'Conversation History', 'Archive', 'Junk Email', 'Junk E-mail', 'Deleted Items', 'Notes', 'Sync Issues', 'Outbox') # Rule conditions that hide replies about the fraud or the compromise. $script:NSPRuleKeywordPattern = '(?i)\b(invoice|payment|wire|ach|bank|remit|payroll|w-?2|hack|hacked|phish|spam|compromis|suspicious|password|mfa|helpdesk|help desk|virus|scam|fraud)' # Audit operations that change inbox rules or mailbox forwarding. $script:NSPRuleOperations = @('New-InboxRule', 'Set-InboxRule', 'Enable-InboxRule', 'Disable-InboxRule', 'Remove-InboxRule', 'UpdateInboxRules', 'Set-Mailbox') # Audit operations that grant an app access to the tenant or a user's data. $script:NSPConsentOperations = @('Consent to application.', 'Add delegated permission grant.', 'Add app role assignment grant to user.', 'Add app role assignment to service principal.', 'Add service principal.') # Microsoft's ASN. Exchange-side activity comes from it, but so do Azure VMs: never trusted # implicitly, only exempt from the hosting-provider finding. Pass -TrustedAsn 8075 to trust it. $script:NSPMicrosoftAsn = '8075' |