From 366ddc893a04e02ac0de3f045a60eef7d0a96c37 Mon Sep 17 00:00:00 2001 From: mattspeer Date: Mon, 2 Nov 2026 18:49:17 -0600 Subject: [PATCH] Add scripts/Review-EmailQuarantine.ps1 --- scripts/Review-EmailQuarantine.ps1 | 286 +++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 scripts/Review-EmailQuarantine.ps1 diff --git a/scripts/Review-EmailQuarantine.ps1 b/scripts/Review-EmailQuarantine.ps1 new file mode 100644 index 0000000..9e86989 --- /dev/null +++ b/scripts/Review-EmailQuarantine.ps1 @@ -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) { '' } else { '

No collection errors recorded. Retention and visibility limitations still apply.

' } + $countsHtml = if ($counts.Count) { $counts | ConvertTo-Html -Fragment | Out-String } else { '

No retained records returned in the recent window.

' } + $queueHtml = if ($queue.Count) { $queue | Select-Object Priority, Triage, ReceivedUtc, ExpiresUtc, Sender, Recipients, Subject, Reason, ReleaseStatus, ExpiringSoon, Identity | ConvertTo-Html -Fragment | Out-String } else { '

No queue entries returned.

' } + $html = @" +Email quarantine review +

Email quarantine review

$(& $encode $status.Status). Human review pending.

+

Tenant: $ExpectedTenantId
Collection UTC: $($startUtc.ToString('o')) through $($endUtc.ToString('o'))
Recent window UTC: $($recentUtc.ToString('o')) through $($endUtc.ToString('o'))

+

Retained identities: $($records.Count) | Recent: $($recentRecords.Count) | Review queue: $($queue.Count) | Pending and expiring within $ExpirationWarningHours hours: $($status.ExpiringPendingCount)

+

Coverage and limitations

$(& $encode $limitText)

+

Collection issues

$issueHtml

Recent detections

$countsHtml +

Review queue

Includes recent records and older pending items. Priority is a starting point for review, not a release verdict.

$queueHtml + +"@ + $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 } +}