Public/Find-NSPMDECollision.ps1

function Find-NSPMDECollision {
    <#
    .SYNOPSIS
        Finds every SHARING VIOLATION in a ProcMon capture and names the process that held the file
        open at that moment - so the evidence says whether the EDR sensor (MsSense.exe), the
        antivirus engine (MsMpEng.exe) or something else caused it.
 
    .DESCRIPTION
        The proven signature: the app writes a file, closes it and reopens it exclusively
        (ShareMode: None) a fraction of a millisecond later; MsSense.exe opened the just-written
        file to inspect it and still holds its handle, so the reopen fails with
        STATUS_SHARING_VIOLATION. An antivirus exclusion does not stop the sensor - that needs a
        Defender for Endpoint EDR (Sense) exclusion.
 
        Two passes: the first finds the paths that had a violation, the second walks only those in
        capture order (a busy minute is hundreds of thousands of opens, almost none relevant).
        Every successful CreateFile opens a handle for that process and path, and the process's
        next CloseFile on the path closes the oldest one (ProcMon's CSV has no handle values, so
        overlapping opens by one process pair up in order). At each violation the handles other
        processes hold on the same path are the holders; their close time is filled in as the walk
        continues.
 
        HolderKind is the most specific holder seen: EDR (MsSense.exe or another Sense*.exe),
        then AV (MsMpEng.exe, MpDefenderCoreService.exe, NisSrv.exe), then Other. NotSeen means no
        other process opened the file inside the capture - its handle predates the capture.
 
        ProcessKind classifies the process that was REFUSED the same way. Defender is often the one
        refused - it tries to read a file the app holds exclusively - which is harmless to the app
        and is not evidence; the evidence summary counts those separately.
 
    .PARAMETER InputObject
        Events from ConvertFrom-NSPProcmonLog (pipeline), in capture order.
 
    .EXAMPLE
        ConvertFrom-NSPProcmonLog -Path .\capture.csv | Find-NSPMDECollision | Format-Table Time, ProcessName, HolderKind, Path
 
    .EXAMPLE
        $c = $events | Find-NSPMDECollision; @($c | Where-Object { $_.ProcessKind -eq 'Other' -and $_.HolderKind -eq 'EDR' }).Count
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory, ValueFromPipeline)][Alias('Event')][AllowNull()][object[]]$InputObject)

    begin {
        $all = New-Object Collections.Generic.List[object]
        $violated = New-Object 'Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
        $kindOf = {
            param([string]$Name)
            if ($Name -match '^(MsSense|Sense[A-Za-z]*)\.exe$') { 'EDR' }
            elseif ($Name -match '^(MsMpEng|MpDefenderCoreService|NisSrv)\.exe$') { 'AV' }
            else { 'Other' }
        }
    }
    process {
        foreach ($e in $InputObject) {
            if (-not $e -or -not $e.Path) { continue }
            $all.Add($e)
            if ($e.Result -eq 'SHARING VIOLATION') { $null = $violated.Add([string]$e.Path) }
        }
    }
    end {
        if (-not $violated.Count) { return }
        $open = @{}       # "pid|path" -> handles still open, oldest first
        $byPath = @{}     # path -> every handle still open on it
        $collisions = New-Object Collections.Generic.List[object]

        foreach ($e in $all) {
            if (-not $violated.Contains([string]$e.Path)) { continue }
            $pathKey = ([string]$e.Path).ToUpperInvariant()
            $key = "$($e.PID)|$pathKey"

            if ($e.Operation -eq 'CreateFile' -and $e.Result -eq 'SUCCESS') {
                $handle = [pscustomobject][ordered]@{
                    ProcessName = [string]$e.ProcessName
                    PID         = [string]$e.PID
                    Kind        = & $kindOf ([string]$e.ProcessName)
                    Opened      = [string]$e.Time
                    Closed      = $null
                    HeldMs      = $null
                    ShareMode   = Get-NSPProcmonDetailField -Detail $e.Detail -Name 'ShareMode'
                    OpenedAt    = $e.TimeOfDay
                }
                if (-not $open.ContainsKey($key)) { $open[$key] = New-Object Collections.Generic.List[object] }
                $open[$key].Add($handle)
                if (-not $byPath.ContainsKey($pathKey)) { $byPath[$pathKey] = New-Object Collections.Generic.List[object] }
                $byPath[$pathKey].Add($handle)
            } elseif ($e.Operation -eq 'CloseFile') {
                if ($open.ContainsKey($key) -and $open[$key].Count) {
                    $handle = $open[$key][0]
                    $open[$key].RemoveAt(0)
                    $null = $byPath[$pathKey].Remove($handle)
                    $handle.Closed = [string]$e.Time
                    if ($handle.OpenedAt -is [TimeSpan] -and $e.TimeOfDay -is [TimeSpan]) { $handle.HeldMs = [Math]::Round(($e.TimeOfDay - $handle.OpenedAt).TotalMilliseconds, 4) }
                }
            } elseif ($e.Result -eq 'SHARING VIOLATION') {
                $holders = @(if ($byPath.ContainsKey($pathKey)) { $byPath[$pathKey] | Where-Object { $_.PID -ne [string]$e.PID } })
                $kinds = @($holders | ForEach-Object Kind)
                $holderKind = if ($kinds -contains 'EDR') { 'EDR' } elseif ($kinds -contains 'AV') { 'AV' } elseif ($kinds) { 'Other' } else { 'NotSeen' }
                $collisions.Add([pscustomobject][ordered]@{
                        PSTypeName    = 'NSP.MDE.Collision'
                        Sequence      = $e.Sequence
                        Time          = [string]$e.Time
                        ProcessName   = [string]$e.ProcessName
                        PID           = [string]$e.PID
                        ProcessKind   = & $kindOf ([string]$e.ProcessName)
                        Operation     = [string]$e.Operation
                        Path          = [string]$e.Path
                        DesiredAccess = Get-NSPProcmonDetailField -Detail $e.Detail -Name 'Desired Access'
                        ShareMode     = Get-NSPProcmonDetailField -Detail $e.Detail -Name 'ShareMode'
                        HolderKind    = $holderKind
                        Holders       = $holders
                    })
            }
        }

        # Holders were captured by reference, so their close times are final now. Drop the
        # internal TimeSpan before handing them out.
        foreach ($c in $collisions) {
            $c.Holders = @($c.Holders | Select-Object ProcessName, PID, Kind, Opened, Closed, HeldMs, ShareMode)
            $c
        }
    }
}