Private/MenuHandlers.ps1

# Start-NSPMDETroubleshooter's screens. Each handler works on the session object
# ($script:MDESession) and returns to the menu; answers worth keeping between runs (company, app,
# processes, data folders, look-back) are saved in the MDE work folder.

function Save-NSPMDESessionAnswers {
    $s = $script:MDESession
    try {
        Save-NSPToolAnswers -Tool MDE -Answers ([pscustomobject][ordered]@{
                Company         = $s.Company
                ApplicationName = $s.ApplicationName
                ProcessNames    = @($s.ProcessNames)
                WatchPaths      = @($s.WatchPaths)
                LookbackDays    = $s.LookbackDays
                CaptureFolder   = $s.CaptureDir
            })
    } catch { Write-Warning "Answers not saved: $($_.Exception.Message)" }
}

function Wait-NSPMDEReturn { Read-Host 'Press Enter to return to the menu' | Out-Null }

function Split-NSPMDEList {
    # "a.exe, b.exe" -> @('a.exe','b.exe')
    param([AllowEmptyString()][string]$Text)
    @($Text -split '[,;]' | ForEach-Object { $_.Trim().Trim('"') } | Where-Object { $_ } | Select-Object -Unique)
}

function Show-NSPMDEDashboard {
    $s = $script:MDESession
    Clear-NSPConsole
    Write-NSPConsoleHeader -Title 'MDE Troubleshooter - EDR sharing-violation evidence' -Subtitle "$env:COMPUTERNAME $($s.VersionLabel)"
    $st = $s.Status
    if ($st) {
        $sensor = if ($st.SenseInstalled) { "MsSense $($st.MsSenseVersion) $($st.SenseState)" } else { 'Sense service not installed' }
        $onboard = if ($st.Onboarded) { 'onboarded' } else { 'NOT onboarded' }
        Write-NSPConsoleSegment -Segment @(@(' Sensor ', 'Muted'), @("$sensor, $onboard", $(if ($st.Onboarded) { 'Success' } else { 'Warning' })),
            @(' AV ', 'Muted'), "$($st.AMRunningMode)", @(' Tamper ', 'Muted'), "$($st.IsTamperProtected)")
    }
    $app = if ($s.ApplicationName) { $s.ApplicationName } else { '(not set)' }
    Write-NSPConsoleSegment -Segment @(@(' App ', 'Muted'), $app, @(' Processes ', 'Muted'), $(if ($s.ProcessNames) { $s.ProcessNames -join ', ' } else { '(none yet)' }))
    Write-NSPConsoleSegment -Segment @(@(' Folders ', 'Muted'), $(if ($s.WatchPaths) { $s.WatchPaths -join ', ' } else { '(none yet - found by the capture)' }))
    $pm = if ($s.ProcmonPath) { $s.ProcmonPath } else { 'not found yet (downloaded when a capture starts)' }
    Write-NSPConsoleSegment -Segment @(@(' ProcMon ', 'Muted'), $pm)
    Write-NSPConsoleSegment -Segment @(@(' Saving to ', 'Muted'), $s.CaptureDir)
    if ($s.Capture) {
        $state = if ($s.Capture.Ended) { "stopped $($s.Capture.Ended.ToString('HH:mm:ss'))" } else { "RUNNING since $($s.Capture.Started.ToString('HH:mm:ss'))" }
        Write-NSPConsoleSegment -Segment @(@(' Capture ', 'Muted'), @($state, $(if ($s.Capture.Ended) { 'Normal' } else { 'Accent' })), @(" $($s.Capture.BackingFile)", 'Muted'))
    }
    if ($null -ne $s.Collisions) {
        $sum = Get-NSPMDEEvidenceSummary -Collision $s.Collisions
        $role = if ($sum.HeldByEdr) { 'Success' } elseif ($sum.Violations) { 'Warning' } else { 'Muted' }
        Write-NSPConsoleSegment -Segment @(@(' Evidence ', 'Muted'), @(("{0} violation(s), MsSense held {1}, MsMpEng held {2}" -f $sum.Violations, $sum.HeldByEdr, $sum.HeldByAv), $role))
    }
    Write-Host ''
}

function Resolve-NSPMDEProcmon {
    # The session's ProcMon, finding it - or with the tech's go-ahead downloading it - first.
    $s = $script:MDESession
    if ($s.ProcmonPath -and (Test-Path -LiteralPath $s.ProcmonPath)) { return $s.ProcmonPath }
    $s.ProcmonPath = Find-NSPProcmon
    if ($s.ProcmonPath) { return $s.ProcmonPath }
    Write-NSPConsoleLine 'Process Monitor was not found on this machine.' -Role Warning
    if (-not (Read-NSPConfirm -Prompt 'Download it from live.sysinternals.com (Microsoft-signed, checked before use)?' -DefaultYes)) { return $null }
    try {
        $s.ProcmonPath = Save-NSPProcmon -Confirm:$false
        Write-NSPConsoleLine "Saved $($s.ProcmonPath) (signature checked)." -Role Success
    } catch { Write-NSPConsoleLine "Download failed: $($_.Exception.Message)" -Role Error }
    return $s.ProcmonPath
}

function Write-NSPMDEFilters {
    # The filter files for this session, from the current process names and data folders.
    $s = $script:MDESession
    $dir = $s.CaptureDir
    $s.Filters = [ordered]@{ Violations = (New-NSPProcmonFilter -Kind Violations -OutputPath (Join-Path $dir 'NSP_SharingViolations.pmc') -Confirm:$false).FullName }
    $s.Filters['Processes'] = (New-NSPProcmonFilter -Kind Processes -ProcessName $s.ProcessNames -OutputPath (Join-Path $dir 'NSP_AppAndEdr.pmc') -Confirm:$false).FullName
    if ($s.WatchPaths) { $s.Filters['Activity'] = (New-NSPProcmonFilter -Kind Activity -Path $s.WatchPaths -OutputPath (Join-Path $dir 'NSP_ShareFileActivity.pmc') -Confirm:$false).FullName }
}

# ---------------------------------------------------------------------------
function Invoke-NSPMDEMenuStatus {
    $s = $script:MDESession
    Write-NSPConsoleHeader 'Defender for Endpoint status'
    $s.Status = Get-NSPMDEStatus
    $s.Status | Format-List ComputerName, OperatingSystem, SenseInstalled, SenseState, SenseStartMode, MsSensePath, MsSenseVersion, Onboarded, OrgId,
    AMRunningMode, AMProductVersion, AMEngineVersion, AntivirusSignatureVersion, RealTimeProtectionEnabled, IsTamperProtected, ExclusionsVisible | Out-Host
    if ($s.Status.ExclusionsVisible) {
        Write-NSPConsoleLine 'Antivirus exclusions (these do NOT apply to the EDR sensor):' -Role Strong
        foreach ($name in 'ExclusionPath', 'ExclusionProcess', 'ExclusionExtension') {
            Write-Host (" {0,-20} {1}" -f $name, $(if ($s.Status.$name) { $s.Status.$name -join '; ' } else { '(none)' }))
        }
    } else {
        Write-NSPConsoleLine 'Exclusion lists are hidden (not elevated).' -Role Muted
    }
    if (-not $s.Status.Onboarded) { Write-NSPConsoleLine 'This machine is not onboarded to Defender for Endpoint - an EDR exclusion would not apply here.' -Role Warning }
    Wait-NSPMDEReturn
}

function Invoke-NSPMDEMenuCrashes {
    $s = $script:MDESession
    Write-NSPConsoleHeader 'Crash and hang events'
    Write-NSPConsoleLine 'Finds the executables that crash or hang. The data files they fight over show up in the capture.' -Role Muted
    $days = Read-NSPOptional -Prompt 'Look back how many days' -Default ([string]$s.LookbackDays)
    $n = 0; if ([int]::TryParse($days, [ref]$n) -and $n -gt 0) { $s.LookbackDays = $n }
    Write-NSPConsoleLine "Reading the Application log ($($s.LookbackDays) days)..." -Role Muted
    $events = @(Get-NSPMDECrashEvent -Days $s.LookbackDays)
    $s.CrashEvents = $events
    $candidates = @($events | Get-NSPMDECandidateApp)
    if (-not $candidates) {
        Write-NSPConsoleLine 'No crash or hang events in that window. Enter the app''s executable names in the filter step instead.' -Role Warning
        Wait-NSPMDEReturn; return
    }
    $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
    $events | Sort-Object TimeCreated | Export-Csv -LiteralPath (Join-Path $s.CaptureDir "crash-events_$stamp.csv") -NoTypeInformation -Encoding UTF8

    for ($i = 0; $i -lt $candidates.Count -and $i -lt 20; $i++) {
        $c = $candidates[$i]
        Write-NSPConsoleLine (' {0,2}. ' -f ($i + 1)) -Role Key -NoNewline
        Write-Host ('{0,-32} {1,4} hit(s) last {2:yyyy-MM-dd HH:mm} {3}' -f $c.ExeName, $c.Hits, $c.LastSeen, (@($c.ExePaths) -join '; '))
    }
    Write-Host ''
    $pick = Read-NSPOptional -Prompt 'Which are the app (numbers, comma-separated; blank keeps the current list)'
    if ($pick) {
        $chosen = @(foreach ($p in Split-NSPMDEList $pick) { $k = 0; if ([int]::TryParse($p, [ref]$k) -and $k -ge 1 -and $k -le $candidates.Count) { $candidates[$k - 1] } })
        if ($chosen) {
            $s.Candidates = $chosen
            $s.ProcessNames = @($chosen.ExeName)
        }
    }
    if (-not $s.ApplicationName) { $s.ApplicationName = Read-NSPOptional -Prompt 'App name as the client knows it (e.g. Sage 100)' }
    Save-NSPMDESessionAnswers
    Wait-NSPMDEReturn
}

function Invoke-NSPMDEMenuFilter {
    $s = $script:MDESession
    Write-NSPConsoleHeader 'ProcMon filters'
    Write-NSPConsoleLine 'Three filters are written: violations only (the proof), app + Defender, and file activity in the data folders.' -Role Muted
    Write-NSPConsoleLine 'They only change what ProcMon shows - the capture keeps everything and the analysis reads it all.' -Role Muted
    Write-Host ''
    $current = $s.ProcessNames -join ', '
    $s.ProcessNames = Split-NSPMDEList (Read-NSPOptional -Prompt 'App executable names (comma-separated)' -Default $current)
    $current = $s.WatchPaths -join ', '
    Write-NSPConsoleLine 'Data-folder fragments match anywhere in a path, e.g. \Clients\ or \DATA\ (blank if not known yet).' -Role Muted
    $s.WatchPaths = Split-NSPMDEList (Read-NSPOptional -Prompt 'Data-folder fragments (comma-separated)' -Default $current)
    if (-not $s.ApplicationName) { $s.ApplicationName = Read-NSPOptional -Prompt 'App name as the client knows it (e.g. Sage 100)' }
    Write-NSPMDEFilters
    foreach ($k in $s.Filters.Keys) { Write-NSPConsoleLine (" {0,-11} {1}" -f $k, $s.Filters[$k]) -Role Success }
    Save-NSPMDESessionAnswers
    Wait-NSPMDEReturn
}

function Invoke-NSPMDEMenuStart {
    $s = $script:MDESession
    Write-NSPConsoleHeader 'Start capture'
    if ($s.Capture -and -not $s.Capture.Ended) { Write-NSPConsoleLine 'A capture is already running - stop it first.' -Role Warning; Wait-NSPMDEReturn; return }
    $exe = Resolve-NSPMDEProcmon
    if (-not $exe) { Wait-NSPMDEReturn; return }
    if (-not $s.Filters) { Write-NSPMDEFilters }
    $display = if ($s.Filters.Contains('Activity')) { $s.Filters['Activity'] } else { $s.Filters['Processes'] }

    Write-NSPConsoleLine 'A capture grows by hundreds of MB a minute on a busy machine. Keep it to the reproduction.' -Role Muted
    $minutes = Read-NSPOptional -Prompt 'Stop by itself after how many minutes (blank: until you stop it)'
    $seconds = 0; $m = 0; if ([int]::TryParse($minutes, [ref]$m) -and $m -gt 0) { $seconds = $m * 60 }
    try {
        $s.Capture = Start-NSPProcmonCapture -ProcmonPath $exe -OutputDirectory $s.CaptureDir -FilterPath $display -RuntimeSeconds $seconds -Confirm:$false
        $s.Collisions = $null
    } catch { Write-NSPConsoleLine "Could not start: $($_.Exception.Message)" -Role Error; Wait-NSPMDEReturn; return }
    Write-Host ''
    Write-NSPConsoleLine "Capturing to $($s.Capture.BackingFile)" -Role Success
    Write-NSPConsoleLine 'Now reproduce the failure in the app (save the record, post the batch, ...).' -Role Strong
    Write-NSPConsoleLine 'When it has failed at least once, come back and choose "Stop capture and analyze".' -Role Strong
    Wait-NSPMDEReturn
}

function Invoke-NSPMDEAnalysis {
    # Exports a capture, finds the collisions, and shows the verdict.
    param([Parameter(Mandatory)][string]$Path)
    $s = $script:MDESession
    $exe = $null
    if ([IO.Path]::GetExtension($Path) -ieq '.pml') {
        $exe = Resolve-NSPMDEProcmon
        if (-not $exe) { return }
        Write-NSPConsoleLine 'Exporting the capture (file opens and closes) - this can take a few minutes...' -Role Muted
    }
    try {
        $events = ConvertFrom-NSPProcmonLog -Path $Path -ProcmonPath $exe
        $s.Collisions = @($events | Find-NSPMDECollision)
    } catch { Write-NSPConsoleLine "Analysis failed: $($_.Exception.Message)" -Role Error; return }

    $sum = Get-NSPMDEEvidenceSummary -Collision $s.Collisions
    Write-Host ''
    Write-NSPConsoleLine ("App sharing violations: {0} held by MsSense (EDR): {1} MsMpEng (AV): {2} other: {3} holder not seen: {4}" -f $sum.Violations, $sum.HeldByEdr, $sum.HeldByAv, $sum.HeldByOther, $sum.HolderNotSeen) -Role Strong
    if ($sum.DefenderRefused) { Write-NSPConsoleLine "($($sum.DefenderRefused) more where Defender itself was refused - harmless to the app, not counted.)" -Role Muted }
    Write-NSPConsoleLine $sum.Verdict -Role $(if ($sum.HeldByEdr) { 'Success' } else { 'Warning' })
    $app = @(Select-NSPMDEAppCollision -Collision $s.Collisions)
    if ($app) {
        Write-Host ''
        $app | Sort-Object @{ e = { $_.HolderKind -ne 'EDR' } }, Sequence | Select-Object -First 15 Time, ProcessName, HolderKind,
        @{ n = 'Holder'; e = { @($_.Holders | ForEach-Object { "$($_.ProcessName) ($($_.HeldMs) ms)" }) -join ', ' } }, ShareMode, Path |
            Format-Table -AutoSize -Wrap | Out-Host
        # New data folders the tech didn't know about: offer them for the activity filter.
        $folders = @($app | ForEach-Object { Split-Path -Parent $_.Path } | Where-Object { $_ } | Sort-Object -Unique)
        if ($folders -and -not $s.WatchPaths) {
            Write-NSPConsoleLine "Contested folders: $($folders -join ', ')" -Role Muted
            if (Read-NSPConfirm -Prompt 'Use them as the data folders for the activity filter?' -DefaultYes) {
                $s.WatchPaths = @($folders | ForEach-Object { $_ + '\' })
                Write-NSPMDEFilters
                Save-NSPMDESessionAnswers
            }
        }
    }
}

function Invoke-NSPMDEMenuStop {
    $s = $script:MDESession
    Write-NSPConsoleHeader 'Stop capture and analyze'
    if (-not $s.Capture) { Write-NSPConsoleLine 'No capture in this session - use "Analyze an existing capture" for one from earlier.' -Role Warning; Wait-NSPMDEReturn; return }
    if (-not $s.Capture.Ended) {
        Write-NSPConsoleLine 'Stopping ProcMon and waiting for it to finish writing...' -Role Muted
        try { $s.Capture = $s.Capture | Stop-NSPProcmonCapture -Confirm:$false } catch { Write-NSPConsoleLine "Stop failed: $($_.Exception.Message)" -Role Error; Wait-NSPMDEReturn; return }
    }
    if (Test-Path -LiteralPath $s.Capture.BackingFile) {
        Write-NSPConsoleLine ("Capture: {0} ({1:N0} MB)" -f $s.Capture.BackingFile, ((Get-Item -LiteralPath $s.Capture.BackingFile).Length / 1MB)) -Role Muted
        if ($s.Capture.PSObject.Properties['Incomplete'] -and $s.Capture.Incomplete) {
            Write-NSPConsoleLine ("ProcMon wrote an incomplete capture: it covers only {0:N0} seconds. Start a new capture and reproduce again." -f $s.Capture.CoveredSeconds) -Role Error
            Write-NSPConsoleLine 'Analyzing what is there, but "no violations" from this capture proves nothing.' -Role Warning
        }
        Invoke-NSPMDEAnalysis -Path $s.Capture.BackingFile
    }
    Wait-NSPMDEReturn
}

function Invoke-NSPMDEMenuAnalyze {
    $s = $script:MDESession
    Write-NSPConsoleHeader 'Analyze an existing capture'
    Write-NSPConsoleLine 'A ProcMon .pml, or a .csv saved from ProcMon with its default columns.' -Role Muted
    $path = $null
    if ($s.Capture -and $s.Capture.BackingFile -and (Test-Path -LiteralPath $s.Capture.BackingFile) -and
        (Read-NSPConfirm -Prompt "This session's capture ($(Split-Path -Leaf $s.Capture.BackingFile))?" -DefaultYes)) {
        $path = $s.Capture.BackingFile
    } else {
        $path = Read-NSPFilePath -Prompt 'Capture file' -Filter 'ProcMon captures (*.pml;*.csv)|*.pml;*.csv|All files (*.*)|*.*' -InitialDirectory $s.CaptureDir -AllowBack
        if (Test-NSPBackSignal $path) { return }
    }
    if (-not $s.Capture -or $s.Capture.BackingFile -ne $path) {
        $item = Get-Item -LiteralPath $path
        $s.Capture = [pscustomobject][ordered]@{
            PSTypeName = 'NSP.MDE.Capture'; ProcmonPath = $s.ProcmonPath; ProcmonVersion = ''; BackingFile = $(if ($item.Extension -ieq '.pml') { $item.FullName } else { '' })
            FilterPath = ''; RuntimeSeconds = 0; ProcessId = $null; Started = $null; Ended = $item.LastWriteTime
        }
    }
    Invoke-NSPMDEAnalysis -Path $path
    Wait-NSPMDEReturn
}

function Invoke-NSPMDEMenuFolder {
    $s = $script:MDESession
    Write-NSPConsoleHeader 'Capture folder'
    if ($s.Capture -and -not $s.Capture.Ended) { Write-NSPConsoleLine 'A capture is running - stop it first.' -Role Warning; Wait-NSPMDEReturn; return }
    Write-NSPConsoleLine 'Captures, filters and crash-event CSVs go here. A capture can grow by hundreds of MB a minute - pick a drive with room.' -Role Muted
    $dir = Read-NSPFolderPath -Prompt 'Capture folder' -Default $s.CaptureDir -Create -AllowBack
    if (Test-NSPBackSignal $dir) { return }
    if ($dir -ne $s.CaptureDir) {
        $s.CaptureDir = $dir
        $s.Filters = $null   # rewritten into the new folder when next needed
        Save-NSPMDESessionAnswers
    }
    try {
        $free = (New-Object IO.DriveInfo([IO.Path]::GetPathRoot($dir))).AvailableFreeSpace / 1GB
        Write-NSPConsoleLine ("Capture folder: {0} ({1:N1} GB free)" -f $dir, $free) -Role Success
    } catch { Write-NSPConsoleLine "Capture folder: $dir" -Role Success }
    Wait-NSPMDEReturn
}

function Invoke-NSPMDEMenuView {
    $s = $script:MDESession
    Write-NSPConsoleHeader 'Open the capture in ProcMon'
    if (-not $s.Capture -or -not $s.Capture.BackingFile -or -not (Test-Path -LiteralPath $s.Capture.BackingFile)) { Write-NSPConsoleLine 'No .pml capture to open.' -Role Warning; Wait-NSPMDEReturn; return }
    if (-not $s.Capture.Ended) { Write-NSPConsoleLine 'Stop the capture first.' -Role Warning; Wait-NSPMDEReturn; return }
    $exe = Resolve-NSPMDEProcmon
    if (-not $exe) { Wait-NSPMDEReturn; return }
    if (-not $s.Filters) { Write-NSPMDEFilters }
    Write-NSPConsoleLine 'For screenshots in the support case: the violations view first, then the activity view showing the MsSense handle around it.' -Role Muted
    $choices = @(foreach ($k in $s.Filters.Keys) { [pscustomobject]@{ Label = $k; Value = $s.Filters[$k] } })
    $filter = Read-NSPChoice -Choices $choices -Prompt 'Filter to open with'
    if (-not $filter) { return }
    $null = Invoke-NSPProcmonExe -FilePath $exe -ArgumentList @('/AcceptEula', '/OpenLog', $s.Capture.BackingFile, '/LoadConfig', $filter)
}

function Invoke-NSPMDEMenuHandoff {
    $s = $script:MDESession
    Write-NSPConsoleHeader 'Write the hand-back'
    Write-NSPConsoleLine 'Writes <Company>_MDE_Response.json: Defender facts, candidate apps, the capture, the collision evidence and its verdict.' -Role Muted
    Write-NSPConsoleLine 'The ProcMon files stay next to the capture; attach the .pml to the support case if asked.' -Role Muted
    Write-Host ''
    $company = ''
    while (-not $company) { $company = Read-NSPOptional -Prompt 'Company' -Default $s.Company }
    $s.Company = $company
    $s.ApplicationName = Read-NSPOptional -Prompt 'App name as the client knows it' -Default $s.ApplicationName
    $notes = Read-NSPOptional -Prompt 'Notes for the request (what the users see, when it started; blank for none)'
    Save-NSPMDESessionAnswers
    if (-not $s.Status) { $s.Status = Get-NSPMDEStatus }
    $candidates = if ($s.Candidates) { $s.Candidates } elseif ($s.CrashEvents) { @($s.CrashEvents | Get-NSPMDECandidateApp -ProcessName $s.ProcessNames) } else { @() }
    try {
        $file = Export-NSPMDEResponse -Company $company -ApplicationName $s.ApplicationName -Status $s.Status -Candidate $candidates `
            -Capture $s.Capture -Collision $s.Collisions -TechNotes $notes -Confirm:$false
    } catch { Write-NSPConsoleLine "ERROR: $($_.Exception.Message)" -Role Error; Wait-NSPMDEReturn; return }
    Write-Host ''
    Write-NSPConsoleLine "Written: $($file.FullName)" -Role Success
    if ($null -eq $s.Collisions) { Write-NSPConsoleLine 'No capture was analyzed - the file has the Defender facts and candidates only.' -Role Warning }
    Write-NSPConsoleLine 'Copy it back for the EDR exclusion request.' -Role Strong
    Open-NSPOutputFolder -Path $file.FullName
    Wait-NSPMDEReturn
}