Public/Export-NSPM365Message.ps1
|
function Export-NSPM365Message { <# .SYNOPSIS Saves mailbox messages, found by InternetMessageId, as .eml files with a SHA-256 manifest - the messages an attacker read, from Get-NSPM365AccessedMessage. .DESCRIPTION For each mailbox and InternetMessageId, finds the message in the mailbox through Microsoft Graph (any folder, Deleted Items included) and saves its full MIME content as <OutputPath>\<mailbox>\<received>_<subject>.eml. Manifest.csv lists every request with its status (Exported, NotFound, Failed), the file and its SHA-256 hash. Takes Get-NSPM365AccessedMessage rows from the pipeline (mailbox from MailboxOwnerUPN, else UserId; Sync rows have no InternetMessageId and are skipped), or -Mailbox with -InternetMessageId. Duplicates are fetched once. NotFound usually means the message was purged (hard-deleted past Recoverable Items) or lives in another mailbox; the audit record still proves it was read. Read-only. Sign-in goes through NSP.M365.Graph: app-only with -ClientId and -CertificateThumbprint (Mail.Read application permission - the usual route for someone else's mailbox), or delegated with Mail.Read.Shared, which also needs Full Access to the mailbox. .PARAMETER InputObject Rows with an InternetMessageId and a MailboxOwnerUPN, UserId or Mailbox property. .PARAMETER Mailbox The mailbox (UPN) to search. .PARAMETER InternetMessageId One or more InternetMessageIds ('<abc@namprd13.prod.outlook.com>'). .PARAMETER OutputPath Export folder. Defaults to .\MessageExport_<timestamp>. .PARAMETER TenantId Tenant ID or domain to sign in to. .PARAMETER ClientId App-only: an app with the Mail.Read application permission in the tenant. Needs -CertificateThumbprint. .PARAMETER CertificateThumbprint Certificate for -ClientId. .PARAMETER UseDeviceCode Delegated sign-in with a device code. .EXAMPLE Get-NSPM365AccessedMessage -Path .\audit.csv | Where-Object ClientIP -eq 203.0.113.50 | Export-NSPM365Message -TenantId contoso.com -ClientId $appId -CertificateThumbprint $thumb .EXAMPLE Export-NSPM365Message -Mailbox jdoe@contoso.com -InternetMessageId '<abc@contoso.com>' -OutputPath .\evidence #> [CmdletBinding(DefaultParameterSetName = 'Pipeline')] param( [Parameter(Mandatory, ValueFromPipeline, ParameterSetName = 'Pipeline')] [psobject[]]$InputObject, [Parameter(Mandatory, ParameterSetName = 'Explicit')] [string]$Mailbox, [Parameter(Mandatory, ParameterSetName = 'Explicit')] [string[]]$InternetMessageId, [string]$OutputPath, [string]$TenantId, [string]$ClientId, [string]$CertificateThumbprint, [switch]$UseDeviceCode ) begin { if ([bool]$ClientId -ne [bool]$CertificateThumbprint) { throw 'App-only sign-in needs both -ClientId and -CertificateThumbprint.' } $wanted = New-Object System.Collections.Generic.List[object] $seen = @{} $add = { param($Box, $Id) $Box = ([string]$Box).Trim() $Id = ([string]$Id).Trim() if (-not $Box -or -not $Id) { return } $key = "$($Box.ToLowerInvariant())|$Id" if ($seen.ContainsKey($key)) { return } $seen[$key] = $true $wanted.Add([pscustomobject]@{ Mailbox = $Box; InternetMessageId = $Id }) } } process { if ($PSCmdlet.ParameterSetName -eq 'Explicit') { foreach ($id in $InternetMessageId) { & $add $Mailbox $id } return } foreach ($row in $InputObject) { if ($null -eq $row -or -not $row.PSObject.Properties['InternetMessageId']) { continue } $box = foreach ($name in 'MailboxOwnerUPN', 'Mailbox', 'UserId') { if ($row.PSObject.Properties[$name] -and $row.$name) { [string]$row.$name; break } } & $add $box $row.InternetMessageId } } end { $ErrorActionPreference = 'Stop' if (-not $wanted.Count) { Write-Warning 'No messages to export (rows need an InternetMessageId and a mailbox).'; return } if (-not $OutputPath) { $OutputPath = Join-Path (Get-Location).ProviderPath ('MessageExport_{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 Import-NSPM365Graph $tenant = if ($TenantId) { Resolve-NSPHuntTenantId -Tenant $TenantId } else { $null } Connect-NSPHuntTenant -TenantId $tenant -Scopes @('Mail.Read.Shared') -ClientId $ClientId -CertificateThumbprint $CertificateThumbprint -UseDeviceCode:$UseDeviceCode $fake = (Get-NSPGraphTransport).Name -eq 'Fake' Write-Host "Exporting $($wanted.Count) message(s) to $OutputPath" -ForegroundColor Cyan $manifest = New-Object System.Collections.Generic.List[object] $row = { param($Want, $Status, $Message, $File, $Note) $entry = [pscustomobject]@{ Mailbox = $Want.Mailbox InternetMessageId = $Want.InternetMessageId Status = $Status Subject = if ($Message) { [string]$Message['subject'] } else { '' } From = if ($Message) { [string](Get-NSPHuntValue $Message @('from', 'emailAddress', 'address')) } else { '' } ReceivedUtc = if ($Message) { Format-NSPHuntUtc $Message['receivedDateTime'] } else { '' } File = if ($File) { $File.Substring($OutputPath.Length).TrimStart('\', '/') } else { '' } Sha256 = if ($File) { (Get-FileHash -LiteralPath $File -Algorithm SHA256).Hash } else { '' } GraphId = if ($Message) { [string]$Message['id'] } else { '' } Note = $Note } $manifest.Add($entry) $entry } $index = 0 foreach ($want in $wanted) { $index++ Write-Progress -Activity 'Exporting messages' -Status "$($want.Mailbox): $($want.InternetMessageId)" -PercentComplete (100 * $index / $wanted.Count) $user = [uri]::EscapeDataString($want.Mailbox) try { $filter = "internetMessageId eq '$($want.InternetMessageId.Replace("'", "''"))'" $uri = New-NSPHuntFilterUri -Base "users/$user/messages" -Filter $filter -Extra '$select=id,subject,from,receivedDateTime' $found = @((Invoke-NSPGraphRetry { Invoke-NSPGraphRequest -Uri $uri })['value'] | Where-Object { $_ }) } catch { & $row $want 'Failed' $null $null $_.Exception.Message continue } if (-not $found.Count) { & $row $want 'NotFound' $null $null 'Not in the mailbox: purged, or in another mailbox.' continue } $folder = Join-Path $OutputPath ($want.Mailbox -replace '[^\w\.@-]', '_') if (-not (Test-Path -LiteralPath $folder)) { New-Item -ItemType Directory -Path $folder -Force | Out-Null } $copy = 0 foreach ($message in $found) { $copy++ $stamp = try { (ConvertTo-NSPHuntUtc $message['receivedDateTime']).ToString('yyyyMMdd-HHmmss') } catch { 'unknown' } $subject = ([string]$message['subject'] -replace '[^\w\.\- ]', '_').Trim() if ($subject.Length -gt 60) { $subject = $subject.Substring(0, 60).Trim() } $base = "{0}_{1}" -f $stamp, $(if ($subject) { $subject } else { 'no-subject' }) $file = Join-Path $folder "$base.eml" $n = 1 while (Test-Path -LiteralPath $file) { $n++; $file = Join-Path $folder "$base ($n).eml" } $value = "users/$user/messages/$([uri]::EscapeDataString([string]$message['id']))/`$value" try { if ($fake) { [IO.File]::WriteAllText($file, [string](Invoke-NSPGraphRequest -Uri $value), (New-Object Text.UTF8Encoding $false)) } else { Invoke-NSPGraphRetry { Invoke-MgGraphRequest -Method GET -Uri "v1.0/$value" -OutputFilePath $file -ErrorAction Stop } | Out-Null } $note = if ($found.Count -gt 1) { "Copy $copy of $($found.Count) in the mailbox." } else { '' } & $row $want 'Exported' $message $file $note } catch { & $row $want 'Failed' $message $null $_.Exception.Message } } } Write-Progress -Activity 'Exporting messages' -Completed $csv = Join-Path $OutputPath 'Manifest.csv' $manifest | Export-Csv -LiteralPath $csv -NoTypeInformation -Encoding UTF8 $counts = $manifest | Group-Object Status | ForEach-Object { "$($_.Name) $($_.Count)" } Write-Host "$($counts -join ', '). Manifest: $csv" -ForegroundColor Cyan } } |