Public/Get-MsecDefenderEmailStats.ps1
|
function Get-MsecDefenderEmailStats { <# .SYNOPSIS Inbound email volume and threat breakdown over the last N days. .DESCRIPTION Queries Microsoft 365 Defender's advanced-hunting EmailEvents table via Microsoft Graph (/security/runHuntingQuery) and returns a single summary row covering the requested window: - Total inbound volume - DeliveryAction counts: Delivered / Junked / Blocked / Replaced - ThreatTypes counts: Phishing / Spam / Malware - Percentages of Total for each of the above Direction is filtered to Inbound only - "phishing stats" almost always means "what attackers sent into the org". Outbound/intra-org are excluded by the KQL. Counting note: ThreatTypes is a comma-separated multi-value column. A single email can simultaneously be Phish AND Spam, in which case it counts in both `Phishing` and `Spam`. Means `Phishing + Spam + Malware` will not in general add up to Total. Requires the 'ThreatHunting.Read.All' application permission on the msec app registration (admin-consent required). A clearer error is raised on the typical 403. .PARAMETER Days Window size in days. Default 7 (Microsoft 365 Defender portal default). .EXAMPLE # Last 7 days, single summary row. Get-MsecDefenderEmailStats .EXAMPLE Get-MsecDefenderEmailStats -Days 30 | Format-Table -AutoSize .EXAMPLE # Combine with the other Defender scores for an archive snapshot. $snapshot = [pscustomobject]@{ CapturedAtUtc = (Get-Date).ToUniversalTime().ToString('u') SecureScore = Get-MsecSecureScore -Top 1 Email = Get-MsecDefenderEmailStats -Days 30 } .OUTPUTS PSCustomObject with StartDate, EndDate, Total, the four DeliveryAction counts, the three ThreatTypes counts, and their percentages of Total. #> [CmdletBinding()] param( [Parameter()] [ValidateRange(1, 180)] [int] $Days = 7 ) Assert-MsecSession # The summarize gives us exactly one row regardless of email volume - no paging, # no client-side aggregation. countif() uses == for DeliveryAction (single value) # and `has` for ThreatTypes (multi-value tokenised match). $kql = @" EmailEvents | where Timestamp >= ago(${Days}d) and EmailDirection == "Inbound" | summarize Total = count(), Delivered = countif(DeliveryAction == "Delivered"), Junked = countif(DeliveryAction == "JunkFolder"), Blocked = countif(DeliveryAction == "Blocked"), Replaced = countif(DeliveryAction == "Replaced"), Phishing = countif(ThreatTypes has "Phish"), Spam = countif(ThreatTypes has "Spam"), Malware = countif(ThreatTypes has "Malware") "@ try { $response = Invoke-MsecGraphRequest ` -Path '/v1.0/security/runHuntingQuery' ` -Method POST ` -Body @{ Query = $kql } } catch { # 403 here is overwhelmingly "you forgot to consent ThreatHunting.Read.All". # Promote it to a clearer message so the user doesn't have to dig. if ($_.Exception.Message -match '403|Forbidden') { throw "Forbidden when calling /security/runHuntingQuery. The msec app needs the 'ThreatHunting.Read.All' application permission (admin consent required). Add it in Entra > App registrations > <msec-app> > API permissions, grant admin consent, then retry. Original error: $($_.Exception.Message)" } throw } # Response shape: { Schema: [...], Results: [{ Total: N, Delivered: N, ... }] }. # With our summarize there's always exactly one row - but defensively handle the # zero-row case (could happen on a fresh tenant with no email history). $row = if ($response.Results -and @($response.Results).Count -gt 0) { $response.Results[0] } else { @{} } # Numeric coalesce - PSObject property access on a missing key returns $null, # and (int)$null == 0, but the explicit coalesce makes intent obvious. $intOf = { param($name) [int](($row.$name) ?? 0) } $total = & $intOf 'Total' $pct = { param($n) if ($total -gt 0) { [math]::Round(($n / $total) * 100, 2) } else { 0.0 } } $delivered = & $intOf 'Delivered' $junked = & $intOf 'Junked' $blocked = & $intOf 'Blocked' $replaced = & $intOf 'Replaced' $phishing = & $intOf 'Phishing' $spam = & $intOf 'Spam' $malware = & $intOf 'Malware' [PSCustomObject]@{ StartDate = (Get-Date).Date.AddDays(-$Days) EndDate = (Get-Date).Date Total = $total Delivered = $delivered Junked = $junked Blocked = $blocked Replaced = $replaced Phishing = $phishing Spam = $spam Malware = $malware DeliveredPercent = & $pct $delivered JunkedPercent = & $pct $junked BlockedPercent = & $pct $blocked PhishingPercent = & $pct $phishing SpamPercent = & $pct $spam MalwarePercent = & $pct $malware } } |