Add scripts/Review-EmailQuarantine.ps1

This commit is contained in:
mattspeer committed 2026-11-02 18:49:17 -06:00
1 parent 6768228eb2
commit 366ddc893a
1 file changed
+286
+286
View File
@@ -0,0 +1,286 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Collects Microsoft 365 email quarantine metadata and builds a review queue.
.DESCRIPTION
Read-only tenant operations. Does not release, deny, delete, submit samples,
create allow entries, open links, download attachments, or change policies.
Requires ExchangeOnlineManagement and tenant-wide quarantine read permissions.
The report is a triage aid, not a completed security review.
Default collection: 30 days of still-retained email, with an 8-day recent window
(7 days plus a 24-hour overlap). Older pending items remain in the queue.
Expired/deleted records cannot be reconstructed. Store output in a restricted
folder; metadata and optional headers can contain confidential information.
.EXAMPLE
.\Review-EmailQuarantine.ps1 -ExpectedTenantId 'YOUR-TENANT-GUID' -UserPrincipalName 'admin@example.com'
.EXAMPLE
.\Review-EmailQuarantine.ps1 -ExpectedTenantId 'YOUR-TENANT-GUID' -UseExistingConnection -IncludeHeaders -OutputDirectory 'D:\Restricted\Quarantine'
.NOTES
Install prerequisite yourself if needed:
Install-Module ExchangeOnlineManagement -Scope CurrentUser
For scheduled use, establish an approved app-only connection first, then use
-UseExistingConnection. This script does not install modules or schedule itself.
Documentation:
https://learn.microsoft.com/powershell/module/exchangepowershell/get-quarantinemessage
https://learn.microsoft.com/powershell/module/exchangepowershell/get-quarantinemessageheader
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][guid]$ExpectedTenantId,
[string]$UserPrincipalName,
[switch]$UseExistingConnection,
[ValidateRange(1, 29)][int]$ReviewDays = 7,
[ValidateRange(0, 24)][int]$OverlapHours = 24,
[ValidateRange(1, 30)][int]$LookbackDays = 30,
[ValidateRange(1, 168)][int]$ExpirationWarningHours = 48,
[ValidateRange(1, 1000)][int]$PageSize = 1000,
[switch]$IncludeHeaders,
[string]$OutputDirectory = (Join-Path $PSScriptRoot 'QuarantineReports')
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if (($ReviewDays * 24 + $OverlapHours) -gt ($LookbackDays * 24)) {
throw 'LookbackDays must cover ReviewDays plus OverlapHours.'
}
$endUtc = [datetime]::UtcNow
$startUtc = $endUtc.AddDays(-$LookbackDays)
$recentUtc = $endUtc.AddDays(-$ReviewDays).AddHours(-$OverlapHours)
$runDirectory = Join-Path $OutputDirectory ($endUtc.ToString('yyyyMMddTHHmmssZ') + '-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
$null = New-Item -ItemType Directory -Path $runDirectory -Force
$issues = [System.Collections.Generic.List[string]]::new()
$connectedHere = $false
$status = [ordered]@{
Status = 'Incomplete'; TenantId = "$ExpectedTenantId"; CollectionStartUtc = $startUtc.ToString('o')
CollectionEndUtc = $endUtc.ToString('o'); RecentWindowStartUtc = $recentUtc.ToString('o')
ReviewStatus = 'Human review pending'; Issues = @()
}
$statusPath = Join-Path $runDirectory 'RunStatus.json'
$status | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $statusPath -Encoding UTF8
function Get-Field($Item, [string[]]$Names) {
foreach ($name in $Names) {
$property = $Item.PSObject.Properties[$name]
if ($null -ne $property -and $null -ne $property.Value -and [string]$property.Value -ne '') {
return $property.Value
}
}
return $null
}
function ConvertTo-Utc($Value) {
if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) { return $null }
if ($Value -is [datetimeoffset]) { return $Value.UtcDateTime }
if ($Value -is [datetime]) {
if ($Value.Kind -eq [DateTimeKind]::Unspecified) {
# Exchange service timestamps without an offset are interpreted as UTC.
return [datetime]::SpecifyKind($Value, [DateTimeKind]::Utc)
}
return $Value.ToUniversalTime()
}
return [datetimeoffset]::Parse([string]$Value, [cultureinfo]::InvariantCulture,
[Globalization.DateTimeStyles]::AssumeUniversal).UtcDateTime
}
function Invoke-ReadWithRetry([scriptblock]$Operation) {
for ($attempt = 1; $attempt -le 3; $attempt++) {
try { return (& $Operation) }
catch {
if ($attempt -eq 3) { throw }
Start-Sleep -Seconds ([math]::Pow(2, $attempt))
}
}
}
function Protect-CsvText($Value) {
$text = [string]$Value
# Email-controlled strings must not become spreadsheet formulas.
if ($text -match '^[\s]*[=+@-]' -or $text -match '^[\t\r\n]') { return "'" + $text }
return $text
}
function Export-ReviewCsv([object[]]$Items, [string]$Path, [string[]]$Columns) {
if ($Items.Count -eq 0) {
(($Columns | ForEach-Object { '"' + $_ + '"' }) -join ',') | Set-Content -LiteralPath $Path -Encoding UTF8
return
}
$Items | ForEach-Object {
$record = [ordered]@{}
foreach ($column in $Columns) { $record[$column] = Protect-CsvText $_.$column }
[pscustomobject]$record
} | Export-Csv -LiteralPath $Path -NoTypeInformation -Encoding UTF8
}
try {
if (-not $UseExistingConnection) {
Import-Module ExchangeOnlineManagement -ErrorAction Stop
$existingConnections = @(Get-ConnectionInformation -ErrorAction Stop | Where-Object { $_.State -eq 'Connected' })
if ($existingConnections.Count) {
throw 'An Exchange connection already exists. Use -UseExistingConnection or start a fresh PowerShell session.'
}
$connectParameters = @{ ShowBanner = $false; ErrorAction = 'Stop' }
if ($UserPrincipalName) { $connectParameters.UserPrincipalName = $UserPrincipalName }
Connect-ExchangeOnline @connectParameters
$connectedHere = $true
}
# Avoid ambiguity about which tenant the imported Exchange commands target.
$connections = @(Get-ConnectionInformation -ErrorAction Stop | Where-Object { $_.State -eq 'Connected' })
if ($connections.Count -ne 1) { throw 'Use a PowerShell session with exactly one connected Exchange Online tenant.' }
if ([string]$connections[0].TenantID -ne [string]$ExpectedTenantId) {
throw 'Connected tenant does not match ExpectedTenantId. Collection stopped.'
}
$null = Get-Command Get-QuarantineMessage -ErrorAction Stop
if ($IncludeHeaders) { $null = Get-Command Get-QuarantineMessageHeader -ErrorAction Stop }
$seen = @{}
$summaries = [System.Collections.Generic.List[object]]::new()
# Bounded daily slices reduce page-limit risk. Boundaries are deduplicated by identity.
for ($sliceStart = $startUtc; $sliceStart -lt $endUtc; $sliceStart = $sliceEnd) {
$sliceSeen = @{}
$sliceEnd = $sliceStart.AddDays(1)
if ($sliceEnd -gt $endUtc) { $sliceEnd = $endUtc }
for ($page = 1; $page -le 1000; $page++) {
$query = @{
EntityType = 'Email'; StartReceivedDate = $sliceStart; EndReceivedDate = $sliceEnd
IncludeMessagesFromBlockedSenderAddress = $true; Page = $page; PageSize = $PageSize
ErrorAction = 'Stop'
}
$batch = @(Invoke-ReadWithRetry { Get-QuarantineMessage @query })
$newRecords = 0
foreach ($message in $batch) {
$identity = [string](Get-Field $message @('Identity'))
if (-not $identity) { throw 'A quarantine result has no identity; cannot reliably deduplicate records.' }
if (-not $sliceSeen.ContainsKey($identity)) {
$sliceSeen[$identity] = $true
$newRecords++
}
if (-not $seen.ContainsKey($identity)) {
$seen[$identity] = $true
$summaries.Add($message)
}
}
if ($batch.Count -lt $PageSize) { break }
if ($page -eq 1000) { throw 'Reached the service page limit. Coverage is incomplete.' }
if ($page -gt 1 -and $newRecords -eq 0) { throw 'Pagination repeated a full page. Coverage cannot be confirmed.' }
}
}
$raw = [System.Collections.Generic.List[object]]::new()
$records = [System.Collections.Generic.List[object]]::new()
$index = 0
foreach ($summary in $summaries) {
$index++
$identity = [string](Get-Field $summary @('Identity'))
Write-Progress -Activity 'Collecting quarantine details' -Status "$index of $($summaries.Count)" -PercentComplete (100 * $index / [math]::Max(1, $summaries.Count))
$message = $summary
$detailStatus = 'Collected'
try {
$detail = @(Invoke-ReadWithRetry { Get-QuarantineMessage -Identity $identity -EntityType Email -ErrorAction Stop })
if ($detail.Count -ne 1 -or [string](Get-Field $detail[0] @('Identity')) -ne $identity) {
throw 'Detail lookup did not return the single requested identity.'
}
$message = $detail[0]
}
catch {
$detailStatus = 'Unavailable; summary retained'
$issues.Add("Details unavailable for ${identity}: $($_.Exception.Message)")
}
$raw.Add($message)
$received = $null; $expires = $null
try { $received = ConvertTo-Utc (Get-Field $message @('ReceivedTime', 'Received')) }
catch { $issues.Add("Unrecognized received timestamp for $identity") }
try { $expires = ConvertTo-Utc (Get-Field $message @('Expires', 'ExpirationTime', 'ExpiresTime')) }
catch { $issues.Add("Unrecognized expiration timestamp for $identity") }
if ($null -eq $received) { $issues.Add("Received timestamp unavailable for $identity") }
if ($null -eq $expires) { $issues.Add("Expiration unavailable for $identity") }
$reason = [string](Get-Field $message @('QuarantineTypes', 'Type', 'QuarantineType'))
$policy = [string](Get-Field $message @('PolicyName'))
$policyType = [string](Get-Field $message @('PolicyTypes', 'PolicyType'))
$releaseStatus = [string](Get-Field $message @('ReleaseStatus'))
$pending = $releaseStatus -notin @('Released', 'Denied')
$recent = $null -ne $received -and $received -ge $recentUtc -and $received -le $endUtc
$soon = $pending -and $null -ne $expires -and $expires -gt $endUtc -and $expires -le $endUtc.AddHours($ExpirationWarningHours)
$priority = 5; $triage = 'Routine trend review'
if ("$reason $policyType" -match 'HighConf.*Phish|Malware|SafeAttachment') {
$priority = 1; $triage = 'High-risk detection; investigate'
}
elseif ("$reason $policyType" -match 'Phish|Spoof|Imperson') {
$priority = 2; $triage = 'Phishing or anti-phishing policy; inspect detection details'
}
elseif ($releaseStatus -eq 'Requested') { $priority = 3; $triage = 'User release request; validate individually' }
elseif ($soon) { $priority = 4; $triage = 'Pending item nearing expiration' }
if (-not $reason -or -not $releaseStatus -or $detailStatus -ne 'Collected') {
$priority = [math]::Min($priority, 3); $triage += '; incomplete metadata needs review'
}
$recipients = @(Get-Field $message @('RecipientAddress', 'QuarantineUser'))
$records.Add([pscustomobject][ordered]@{
Priority = $priority; Triage = $triage; Identity = $identity
InternetMessageId = [string](Get-Field $message @('MessageId'))
NetworkMessageId = [string](Get-Field $message @('NetworkMessageId'))
ReceivedUtc = $(if ($null -ne $received) { $received.ToString('o') } else { '' })
ExpiresUtc = $(if ($null -ne $expires) { $expires.ToString('o') } else { '' })
Sender = [string](Get-Field $message @('SenderAddress'))
Recipients = ($recipients -join '; '); Subject = [string](Get-Field $message @('Subject'))
Reason = $reason; PolicyName = $policy; PolicyType = $policyType; ReleaseStatus = $releaseStatus
ReleasedRecipients = (@(Get-Field $message @('ReleasedUser')) -join '; ')
RecentWindow = $recent; Pending = $pending; ExpiringSoon = $soon
DetailStatus = $detailStatus; HeaderStatus = 'Not requested'
})
}
Write-Progress -Activity 'Collecting quarantine details' -Completed
$queue = @($records | Where-Object { $_.RecentWindow -or $_.Pending -or $_.DetailStatus -ne 'Collected' } | Sort-Object Priority, ExpiresUtc, ReceivedUtc)
if ($IncludeHeaders) {
$headerDirectory = Join-Path $runDirectory 'Headers'
$null = New-Item -ItemType Directory -Path $headerDirectory
$headerIndex = 0
foreach ($record in $queue) {
$headerIndex++
try {
$header = Invoke-ReadWithRetry { Get-QuarantineMessageHeader -Identity $record.Identity -ErrorAction Stop }
# CLIXML retains the returned header object without rendering HTML.
$header | Export-Clixml -LiteralPath (Join-Path $headerDirectory ("header-{0:D6}.clixml" -f $headerIndex)) -Encoding UTF8
$record.HeaderStatus = "Headers/header-$('{0:D6}' -f $headerIndex).clixml"
}
catch { $record.HeaderStatus = 'Unavailable'; $issues.Add("Header unavailable for $($record.Identity): $($_.Exception.Message)") }
}
}
$columns = @('Priority','Triage','Identity','InternetMessageId','NetworkMessageId','ReceivedUtc','ExpiresUtc','Sender','Recipients','Subject','Reason','PolicyName','PolicyType','ReleaseStatus','ReleasedRecipients','RecentWindow','Pending','ExpiringSoon','DetailStatus','HeaderStatus')
Export-ReviewCsv @($records) (Join-Path $runDirectory 'AllRetained.csv') $columns
Export-ReviewCsv $queue (Join-Path $runDirectory 'ReviewQueue.csv') $columns
ConvertTo-Json -InputObject @($raw) -Depth 12 | Set-Content -LiteralPath (Join-Path $runDirectory 'RawMessages.json') -Encoding UTF8
$recentRecords = @($records | Where-Object { $_.RecentWindow })
$counts = @($recentRecords | Group-Object Reason | Sort-Object Count -Descending | Select-Object @{n='Detection';e={$_.Name}}, Count)
$limitText = 'Collection covers records still retained by Microsoft, not all historical arrivals. Deleted or expired items are absent. Pagination is not a transactional snapshot. Detection categories provide triage only; impersonation and campaign analysis need additional investigation. No content, URL, attachment, sender-authenticity, incident, or delivery validation was performed. Counts are distinct quarantine identities, not unique Internet Message-IDs or message-recipient instances. Service timestamps without an offset are interpreted as UTC. CSV text beginning with spreadsheet formula characters is prefixed with an apostrophe; raw values are preserved in JSON.'
$status.Status = $(if ($issues.Count) { 'Collected with gaps' } else { 'Collection completed' })
$status['RetainedIdentityCount'] = $records.Count
$status['RecentIdentityCount'] = $recentRecords.Count
$status['QueueIdentityCount'] = $queue.Count
$status['ExpiringPendingCount'] = @($records | Where-Object { $_.ExpiringSoon }).Count
$status['Limitations'] = $limitText
$status.Issues = @($issues)
$encode = { param($Text) [System.Net.WebUtility]::HtmlEncode([string]$Text) }
$issueHtml = if ($issues.Count) { '<ul>' + (($issues | ForEach-Object { '<li>' + (& $encode $_) + '</li>' }) -join '') + '</ul>' } else { '<p>No collection errors recorded. Retention and visibility limitations still apply.</p>' }
$countsHtml = if ($counts.Count) { $counts | ConvertTo-Html -Fragment | Out-String } else { '<p>No retained records returned in the recent window.</p>' }
$queueHtml = if ($queue.Count) { $queue | Select-Object Priority, Triage, ReceivedUtc, ExpiresUtc, Sender, Recipients, Subject, Reason, ReleaseStatus, ExpiringSoon, Identity | ConvertTo-Html -Fragment | Out-String } else { '<p>No queue entries returned.</p>' }
$html = @"
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><title>Email quarantine review</title><style>body{font:15px Segoe UI,Arial,sans-serif;margin:32px;color:#172334}h1,h2{color:#172334}table{border-collapse:collapse;width:100%;font-size:13px}td,th{border:1px solid #ccd3dc;padding:8px;text-align:left;vertical-align:top;overflow-wrap:anywhere}th{background:#edf2f7}p{max-width:1100px;line-height:1.5}</style></head><body>
<h1>Email quarantine review</h1><p><strong>$(& $encode $status.Status). Human review pending.</strong></p>
<p>Tenant: $ExpectedTenantId<br>Collection UTC: $($startUtc.ToString('o')) through $($endUtc.ToString('o'))<br>Recent window UTC: $($recentUtc.ToString('o')) through $($endUtc.ToString('o'))</p>
<p>Retained identities: $($records.Count) | Recent: $($recentRecords.Count) | Review queue: $($queue.Count) | Pending and expiring within $ExpirationWarningHours hours: $($status.ExpiringPendingCount)</p>
<h2>Coverage and limitations</h2><p>$(& $encode $limitText)</p>
<h2>Collection issues</h2>$issueHtml<h2>Recent detections</h2>$countsHtml
<h2>Review queue</h2><p>Includes recent records and older pending items. Priority is a starting point for review, not a release verdict.</p>$queueHtml
</body></html>
"@
$html | Set-Content -LiteralPath (Join-Path $runDirectory 'Report.html') -Encoding UTF8
$status | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $statusPath -Encoding UTF8
Write-Host "Reports saved to $runDirectory"
if ($issues.Count) { Write-Warning "$($issues.Count) collection issues; inspect RunStatus.json before using the report." }
[pscustomobject]@{ Status = $status.Status; ReportDirectory = $runDirectory; QueueCount = $queue.Count; Issues = $issues.Count }
}
catch {
$issues.Add($_.Exception.Message)
$status.Status = 'Incomplete'; $status.Issues = @($issues)
$status | ConvertTo-Json -Depth 5 | Set-Content -LiteralPath $statusPath -Encoding UTF8
throw
}
finally {
if ($connectedHere) { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction SilentlyContinue }
}