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