MDOP2-IR.psm1
|
#Requires -Version 7.4 # Root module of MDOP2-IR. The helper modules in Modules\ are imported when the command runs. function Invoke-MDOP2IncidentResponse { <# .SYNOPSIS Finds phishing mail in a Microsoft 365 tenant and moves every copy to the Inbox, Archive, Junk, Deleted Items or Recoverable Items. .DESCRIPTION Signs the admin in, makes sure the tool's app registration and Mail.ReadWrite consent exist in the tenant, creates a short-lived client secret for this session only, and removes that secret again on exit. .PARAMETER TenantId Tenant ID or primary domain of the customer tenant. Leave empty to use the tenant of the account you sign in with. .PARAMETER AppDisplayName Display name of the tool's app registration in the tenant. .PARAMETER SecretLifetimeHours Lifetime of the just-in-time client secret. The secret is removed on exit; this is the fallback if removal can't run. .PARAMETER TestMailbox Console self-test: reads the newest message in this mailbox's Inbox with the app-only token. .PARAMETER TestSearch Console self-test: runs a message trace search and locates the newest matching message in every mailbox. Keys: Range (1d, 7d, 30d, 90d), Subject, SubjectFilterType, Sender, Recipient, Status, MessageId. Example: -TestSearch @{ Range = '7d'; Sender = 'contoso.com'; Subject = 'Invoice' } .PARAMETER TestAction Console self-test: finds one message by Message-ID, runs one or more actions on every copy and shows the status after each. Keys: MessageId (required), Action (MoveToInbox, MoveToArchive, MoveToJunk, MoveToDeletedItems, SoftDelete; one or more, run in order), Range, Execute. Dry run unless Execute = $true. Example: -TestAction @{ MessageId = '<abc@contoso.com>'; Action = 'MoveToJunk' } .PARAMETER NoBrowser Don't open the Microsoft Graph sign-in page and the web UI in the default browser; only print their links. The Exchange Online sign-in always opens in the default browser. .NOTES Without a Test parameter the tool starts the local web UI on 127.0.0.1 and runs until you end the session (Enter or Ctrl+C in the console, or End session in the UI). #> [CmdletBinding()] param( [ValidatePattern('^([0-9a-fA-F-]{36}|[A-Za-z0-9.-]+\.[A-Za-z]{2,})$')] [string]$TenantId, [ValidatePattern('^[A-Za-z0-9 ._-]{3,120}$')] [string]$AppDisplayName = 'Invoke-MDOP2-Incident-Response', [ValidateRange(1, 8)] [int]$SecretLifetimeHours = 2, [ValidatePattern('^[^\s@]+@[^\s@]+\.[^\s@]+$')] [string]$TestMailbox, [hashtable]$TestSearch, [hashtable]$TestAction, [switch]$NoBrowser ) $ErrorActionPreference = 'Stop' Set-StrictMode -Version Latest $webMode = -not ($TestMailbox -or $TestSearch -or $TestAction) $required = @('ExchangeOnlineManagement') if ($webMode) { $required += 'Pode' } foreach ($module in $required) { if (-not (Get-Module -ListAvailable -Name $module)) { throw "Module '$module' is not installed. Run: Install-Module $module -Scope CurrentUser" } } # 3.7.2 added Connect-ExchangeOnline -DisableWAM, which the browser sign-in needs $exoMinimum = [version]'3.7.2' if (-not (Get-Module -ListAvailable -Name ExchangeOnlineManagement | Where-Object Version -ge $exoMinimum)) { throw "ExchangeOnlineManagement $exoMinimum or later is needed. Run: Install-Module ExchangeOnlineManagement -Scope CurrentUser -Force" } # Imported here so an older copy earlier in PSModulePath can't be auto-loaded instead. Global, because the modules # below call each other and the Exchange Online cmdlets, and an import from inside a module is otherwise private to it. Import-Module ExchangeOnlineManagement -MinimumVersion $exoMinimum -Global Import-Module (Join-Path $PSScriptRoot 'Modules\MDOP2.Common.psm1') -Force -Global Import-Module (Join-Path $PSScriptRoot 'Modules\MDOP2.Auth.psm1') -Force -Global Import-Module (Join-Path $PSScriptRoot 'Modules\MDOP2.Audit.psm1') -Force -Global Import-Module (Join-Path $PSScriptRoot 'Modules\MDOP2.Discovery.psm1') -Force -Global Import-Module (Join-Path $PSScriptRoot 'Modules\MDOP2.Actions.psm1') -Force -Global Import-Module (Join-Path $PSScriptRoot 'Modules\MDOP2.Web.psm1') -Force -Global Import-Module (Join-Path $PSScriptRoot 'Modules\MDOP2.Jobs.psm1') -Force -Global if ($TestSearch) { $allowed = 'Range', 'Subject', 'SubjectFilterType', 'Sender', 'Recipient', 'Status', 'MessageId' $unknown = @($TestSearch.Keys | Where-Object { $_ -notin $allowed }) if ($unknown.Count) { throw "Unknown TestSearch key(s): $($unknown -join ', '). Allowed: $($allowed -join ', ')." } } if ($TestAction) { $allowed = 'MessageId', 'Action', 'Range', 'Execute' $unknown = @($TestAction.Keys | Where-Object { $_ -notin $allowed }) if ($unknown.Count) { throw "Unknown TestAction key(s): $($unknown -join ', '). Allowed: $($allowed -join ', ')." } if (-not $TestAction['MessageId'] -or -not $TestAction['Action']) { throw 'TestAction needs MessageId and Action.' } foreach ($a in @($TestAction['Action'])) { $null = Get-MDOP2ActionFolder -Action $a } } function Show-MDOP2Status { param([string]$InternetMessageId, [object[]]$Mailbox) Get-MDOP2MessageStatus -InternetMessageId $InternetMessageId -Mailbox $Mailbox | Format-Table Address, State, @{ n = 'Folders'; e = { $_.Folders -join ', ' } }, Error -AutoSize -Wrap | Out-Host } function Invoke-MDOP2TestAction { param([hashtable]$Spec) $range = Get-MDOP2DateRange -Range ($Spec['Range'] ?? '7d') $search = Search-MDOP2MessageTrace -StartDate $range.Start -EndDate $range.End -MessageId $Spec['MessageId'] $group = $search.Groups | Where-Object InternetMessageId -eq $Spec['MessageId'] | Select-Object -First 1 if (-not $group) { Write-Warning "No message with Message-ID $($Spec['MessageId']) in the last $($Spec['Range'] ?? '7d')."; return } Write-Host "Message: '$($group.Subject)' from $($group.SenderAddress), $($group.RecipientCount) recipient(s)." -ForegroundColor Cyan $dryRun = -not [bool]$Spec['Execute'] $mailboxes = $null foreach ($action in @($Spec['Action'])) { $resolved = @(Resolve-MDOP2MessageGroup -Group $group) if (-not $mailboxes) { $mailboxes = @($resolved | Where-Object UserId | Select-Object Address, UserId) } $plan = @(New-MDOP2ActionPlan -InternetMessageId $group.InternetMessageId -Resolved $resolved -Action $action) $s = Get-MDOP2ActionSummary -Plan $plan $mode = if ($dryRun) { 'DRY RUN' } else { 'LIVE' } Write-Host "[$mode] $action to $(Get-MDOP2ActionFolder -Action $action): $($s.Copies) cop(ies) of $($s.Messages) message(s) in $($s.Mailboxes) mailbox(es), $($s.Skipped) skipped." -ForegroundColor Yellow Invoke-MDOP2MessageAction -Plan $plan -DryRun $dryRun | Format-Table Address, FromFolder, Result, Hold, Error -AutoSize -Wrap | Out-Host Write-Host 'Status now:' -ForegroundColor Cyan if ($mailboxes) { Show-MDOP2Status -InternetMessageId $group.InternetMessageId -Mailbox $mailboxes } } } function Invoke-MDOP2TestSearch { param([hashtable]$Spec) $range = Get-MDOP2DateRange -Range ($Spec['Range'] ?? '7d') $p = @{ StartDate = $range.Start; EndDate = $range.End } foreach ($k in 'Subject', 'SubjectFilterType', 'Sender', 'Recipient', 'Status', 'MessageId') { if ($Spec.ContainsKey($k)) { $p[$k] = $Spec[$k] } } $result = Search-MDOP2MessageTrace @p Write-Host "Message trace: $($result.Rows.Count) row(s) in $($result.Queries) quer(ies), $($result.Groups.Count) message(s)." -ForegroundColor Cyan if ($result.Truncated) { Write-Warning 'Result was cut off at the row limit. Narrow the search.' } $result.Groups | Sort-Object LastReceived -Descending | Select-Object -First 20 | Format-Table @{ n = 'Received (UTC)'; e = { $_.LastReceived.ToString('yyyy-MM-dd HH:mm') } }, SenderAddress, @{ n = 'Subject'; e = { if ($_.Subject.Length -gt 50) { $_.Subject.Substring(0, 47) + '...' } else { $_.Subject } } }, @{ n = 'Rcpts'; e = { $_.RecipientCount } }, @{ n = 'Status'; e = { ($_.StatusCounts.GetEnumerator() | ForEach-Object { "$($_.Key) $($_.Value)" }) -join ', ' } } -AutoSize | Out-Host $newest = $result.Groups | Where-Object InternetMessageId | Sort-Object LastReceived -Descending | Select-Object -First 1 if (-not $newest) { return } Write-Host "Locating '$($newest.Subject)' $($newest.InternetMessageId) in $($newest.RecipientCount) mailbox(es)." -ForegroundColor Cyan Resolve-MDOP2MessageGroup -Group $newest | Format-Table Address, TraceStatus, RecipientTypeDetails, State, @{ n = 'Folders'; e = { @($_.Locations | ForEach-Object FolderName) -join ', ' } }, Detail -AutoSize -Wrap | Out-Host } function Wait-MDOP2ExitKey { # Ctrl+C is read as a key here: some module cmdlets end the process on Ctrl+C before finally can remove the secret Write-Host 'Session is active. Press Enter or Ctrl+C to end it and remove the secret.' -ForegroundColor Cyan if ([Console]::IsInputRedirected) { [void][Console]::In.ReadLine(); return } $previous = [Console]::TreatControlCAsInput [Console]::TreatControlCAsInput = $true try { while ($true) { $k = [Console]::ReadKey($true) if ($k.Key -eq [ConsoleKey]::Enter) { return } if ($k.Key -eq [ConsoleKey]::C -and ($k.Modifiers -band [ConsoleModifiers]::Control)) { return } } } finally { [Console]::TreatControlCAsInput = $previous } } function Test-MDOP2ExitKey { # Non-blocking: true when Enter or Ctrl+C was pressed. Needs TreatControlCAsInput, see Wait-MDOP2ExitKey. if ([Console]::IsInputRedirected) { return $false } while ([Console]::KeyAvailable) { $k = [Console]::ReadKey($true) if ($k.Key -eq [ConsoleKey]::Enter) { return $true } if ($k.Key -eq [ConsoleKey]::C -and ($k.Modifiers -band [ConsoleModifiers]::Control)) { return $true } } $false } function Start-MDOP2WebSession { param([object]$Who, [object]$Secret) $session = @{ AdminUpn = $Who.AdminUpn TenantId = $Who.TenantId TenantName = if ($TenantId) { $TenantId } else { ($Who.AdminUpn -split '@')[-1] } SecretExpires = ConvertTo-MDOP2IsoDate $Secret.Expires } Write-Host 'Starting the web UI.' -ForegroundColor Cyan $state = Start-MDOP2WebServer -Session $session -WebRoot (Join-Path $PSScriptRoot 'Web') $url = "http://127.0.0.1:$($state.Port)/?t=$($state.Token)" Write-Host "Web UI: $url" -ForegroundColor Green Write-Host 'This link signs the browser in to this session. Do not share it.' -ForegroundColor Yellow if (-not $NoBrowser) { Start-Process $url } Write-Host 'Session is active. Press Enter or Ctrl+C here, or End session in the UI, to stop and remove the secret.' -ForegroundColor Cyan # Ctrl+C is read as a key for the whole session so it can never skip the cleanup in finally $redirected = [Console]::IsInputRedirected $previous = if (-not $redirected) { [Console]::TreatControlCAsInput } if (-not $redirected) { [Console]::TreatControlCAsInput = $true } try { while (-not $state.EndRequested) { if (Test-MDOP2ExitKey) { break } if (-not (Test-MDOP2WebServerRunning)) { throw "The web server stopped unexpectedly. $((Get-MDOP2WebServerError) -join ' ')" } $job = $null if ($state.Queue.TryDequeue([ref]$job)) { Write-Host "[$((Get-Date).ToString('HH:mm:ss'))] $($job.Type)" -ForegroundColor DarkGray Invoke-MDOP2Job -Job $job } else { Start-Sleep -Milliseconds 150 } } } finally { if (-not $redirected) { [Console]::TreatControlCAsInput = $previous } Stop-MDOP2WebServer Write-Host 'Web UI stopped.' } } function Wait-MDOP2MailRole { param([int]$TimeoutMinutes = 10) $deadline = [datetime]::UtcNow.AddMinutes($TimeoutMinutes) while ($true) { $token = Get-MDOP2AppToken if (Test-MDOP2TokenHasRole -Token $token -Role 'Mail.ReadWrite') { return } if ([datetime]::UtcNow -ge $deadline) { throw "The app token still has no Mail.ReadWrite permission after $TimeoutMinutes minutes. Try again later." } Write-Host 'Consent is not active yet. Checking again in 30 seconds (Ctrl+C to stop).' -ForegroundColor Yellow (Get-MDOP2Session).Token = $null Start-Sleep -Seconds 30 } } $signedIn = $false try { Write-Host 'Signing in to Microsoft Graph and Exchange Online in your browser.' -ForegroundColor Cyan $who = Connect-MDOP2Admin -TenantId $TenantId -NoBrowser:$NoBrowser $signedIn = $true Write-Host "Signed in as $($who.AdminUpn) to tenant $($who.TenantId)." # Not next to the module: an installed module folder is not a place to write to $auditDir = Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'MDOP2-IR\audit' $null = Initialize-MDOP2Audit -Directory $auditDir -AdminUpn $who.AdminUpn -TenantId $who.TenantId Write-MDOP2Audit -Type SessionStart $app = Initialize-MDOP2App -DisplayName $AppDisplayName if ($app.Changes.Count) { $app.Changes | ForEach-Object { Write-Host " $_" -ForegroundColor Green } } else { Write-Host ' App registration and consent already in place.' } $stale = @(Remove-MDOP2StaleJitSecret) if ($stale.Count) { Write-Host "Removed $($stale.Count) leftover secret(s) from an earlier session." -ForegroundColor Yellow } $secret = New-MDOP2JitSecret -LifetimeHours $SecretLifetimeHours Write-Host "Created session secret $($secret.DisplayName), expires $($secret.Expires.ToString('u'))." Wait-MDOP2MailRole Write-Host 'App-only access is ready.' -ForegroundColor Green if ($TestMailbox) { $upn = [uri]::EscapeDataString($TestMailbox) $r = Invoke-MDOP2AppGraph -Method GET -Path "/users/$upn/mailFolders/inbox/messages?`$top=1&`$select=subject,receivedDateTime,internetMessageId" $m = @($r.value) if ($m.Count) { Write-Host "Newest Inbox message for ${TestMailbox}: [$($m[0].receivedDateTime)] $($m[0].subject)" } else { Write-Host "Inbox of $TestMailbox is empty." } } if ($TestSearch) { Invoke-MDOP2TestSearch -Spec $TestSearch } if ($TestAction) { Invoke-MDOP2TestAction -Spec $TestAction } if ($webMode) { Start-MDOP2WebSession -Who $who -Secret $secret } else { Wait-MDOP2ExitKey } } finally { if ($signedIn) { try { if (Remove-MDOP2JitSecret) { Write-Host 'Session secret removed.' -ForegroundColor Green } } catch { Write-Warning "Could not remove the session secret: $($_.Exception.Message). It expires on its own and is removed on the next start." } try { Write-MDOP2Audit -Type SessionEnd } catch { Write-Verbose "Audit: $($_.Exception.Message)" } Disconnect-MDOP2Admin Write-Host 'Signed out.' } } } Export-ModuleMember -Function Invoke-MDOP2IncidentResponse |