Private/ProcmonHelpers.ps1

# Process Monitor plumbing: the .pmc filter-file format, the signature check every ProcMon binary
# passes before it runs elevated, the runner, and CSV time parsing.

# ---------------------------------------------------------------------------
# .pmc format. Decoded from configuration files generated (and round-tripped) with the Python
# procmon-parser library - Tests\Fixtures\*.pmc are those files, and the writer below must
# reproduce them byte for byte.
#
# record UInt32 RecordSize (header + name + data)
# UInt32 HeaderSize (always 0x10)
# UInt32 DataOffset (0x10 + name bytes)
# UInt32 DataSize
# name UTF-16LE, NUL-terminated ("FilterRules")
# data
# FilterRules data
# Byte 1 (constant)
# Byte rule count
# Byte[3] 0
# rules UInt32 Column, UInt32 Relation, Byte Action (1 include, 0 exclude),
# UInt32 value bytes, value UTF-16LE NUL-terminated, UInt64 0
#
# ProcMon reads a file holding only FilterRules and keeps its other settings (columns, highlight).
# Filters are display-only unless "Drop Filtered Events" is on, which this module never sets: the
# backing file always holds every event, so the analysis can re-filter it.

$script:NSPProcmonColumn = @{
    ProcessName = 40053   # 0x9C75
    PID         = 40054
    Operation   = 40055   # 0x9C77
    Result      = 40056   # 0x9C78
    Detail      = 40057
    ImagePath   = 40068
    Path        = 40071   # 0x9C87
    Duration    = 40077
}

$script:NSPProcmonRelation = @{
    Is = 0; IsNot = 1; LessThan = 2; MoreThan = 3; BeginsWith = 4; EndsWith = 5; Contains = 6; Excludes = 7
}

function ConvertTo-NSPProcmonConfigBytes {
    # The .pmc bytes for a list of rules (objects with Column, Relation, Value, Action).
    param([Parameter(Mandatory)][object[]]$Rule)

    if ($Rule.Count -gt 255) { throw 'A ProcMon filter holds at most 255 rules.' }
    $unicode = [Text.Encoding]::Unicode
    $data = New-Object IO.MemoryStream
    $w = New-Object IO.BinaryWriter($data)
    try {
        $w.Write([byte]1)
        $w.Write([byte]$Rule.Count)
        $w.Write([byte[]](0, 0, 0))
        foreach ($r in $Rule) {
            if (-not $script:NSPProcmonColumn.ContainsKey([string]$r.Column)) { throw "Unknown ProcMon column '$($r.Column)'." }
            if (-not $script:NSPProcmonRelation.ContainsKey([string]$r.Relation)) { throw "Unknown ProcMon relation '$($r.Relation)'." }
            $value = $unicode.GetBytes([string]$r.Value + [char]0)
            $w.Write([uint32]$script:NSPProcmonColumn[[string]$r.Column])
            $w.Write([uint32]$script:NSPProcmonRelation[[string]$r.Relation])
            $w.Write([byte]$(if ($r.Action -eq 'Exclude') { 0 } else { 1 }))
            $w.Write([uint32]$value.Length)
            $w.Write($value)
            $w.Write([uint64]0)
        }
        $w.Flush()
        [byte[]]$body = $data.ToArray()
    } finally { $w.Dispose() }

    $name = $unicode.GetBytes('FilterRules' + [char]0)
    $out = New-Object IO.MemoryStream
    $w = New-Object IO.BinaryWriter($out)
    try {
        $w.Write([uint32](0x10 + $name.Length + $body.Length))
        $w.Write([uint32]0x10)
        $w.Write([uint32](0x10 + $name.Length))
        $w.Write([uint32]$body.Length)
        $w.Write($name)
        $w.Write($body)
        $w.Flush()
        , $out.ToArray()
    } finally { $w.Dispose() }
}

function ConvertFrom-NSPProcmonConfigBytes {
    # Reads the FilterRules record back (tests, and the menu's "what's in this filter" view).
    param([Parameter(Mandatory)][byte[]]$Bytes)
    $columns = @{}; foreach ($k in $script:NSPProcmonColumn.Keys) { $columns[$script:NSPProcmonColumn[$k]] = $k }
    $relations = @{}; foreach ($k in $script:NSPProcmonRelation.Keys) { $relations[$script:NSPProcmonRelation[$k]] = $k }
    $r = New-Object IO.BinaryReader((New-Object IO.MemoryStream(, $Bytes)))
    try {
        while ($r.BaseStream.Position -lt $r.BaseStream.Length) {
            $start = $r.BaseStream.Position
            $size = $r.ReadUInt32(); $null = $r.ReadUInt32(); $offset = $r.ReadUInt32(); $null = $r.ReadUInt32()
            $name = [Text.Encoding]::Unicode.GetString($r.ReadBytes($offset - 0x10)).TrimEnd([char]0)
            if ($name -ne 'FilterRules') { $r.BaseStream.Position = $start + $size; continue }
            $null = $r.ReadByte()
            $count = $r.ReadByte()
            $null = $r.ReadBytes(3)
            for ($i = 0; $i -lt $count; $i++) {
                $column = $r.ReadUInt32(); $relation = $r.ReadUInt32(); $action = $r.ReadByte()
                $value = [Text.Encoding]::Unicode.GetString($r.ReadBytes($r.ReadUInt32())).TrimEnd([char]0)
                $null = $r.ReadUInt64()
                [pscustomobject]@{
                    Column   = if ($columns.ContainsKey([int]$column)) { $columns[[int]$column] } else { "$column" }
                    Relation = if ($relations.ContainsKey([int]$relation)) { $relations[[int]$relation] } else { "$relation" }
                    Value    = $value
                    Action   = if ($action) { 'Include' } else { 'Exclude' }
                }
            }
            $r.BaseStream.Position = $start + $size
        }
    } finally { $r.Dispose() }
}

# ---------------------------------------------------------------------------
function Test-NSPProcmonSignature {
    # True when the file carries a valid Authenticode signature from Microsoft. Every ProcMon
    # binary is checked before it runs: the troubleshooter runs elevated, and some of the folders
    # searched (ProgramData, the script folder) can be written by ordinary users.
    param([Parameter(Mandatory)][string]$Path)
    $sig = Get-AuthenticodeSignature -LiteralPath $Path -ErrorAction SilentlyContinue
    if (-not $sig -or $sig.Status -ne 'Valid' -or -not $sig.SignerCertificate) { return $false }
    return ($sig.SignerCertificate.Subject -match '(^|,\s*)O=Microsoft Corporation(,|$)')
}

function Invoke-NSPProcmonExe {
    # Runs ProcMon with the given arguments. -Wait blocks until it exits (conversions, /Terminate);
    # without it the process object is returned (a capture).
    param([Parameter(Mandatory)][string]$FilePath, [string[]]$ArgumentList, [switch]$Wait)
    $quoted = foreach ($arg in $ArgumentList) { if ($arg -match '[\s"]') { '"' + ($arg -replace '"', '\"') + '"' } else { $arg } }
    Write-Verbose "$FilePath $(@($quoted) -join ' ')"
    $start = @{ FilePath = $FilePath; ArgumentList = @($quoted); PassThru = $true }
    if ($Wait) { $start['Wait'] = $true }
    Start-Process @start
}

function Export-NSPProcmonCsv {
    # One ProcMon export of a .pml to CSV with a filter applied.
    param([Parameter(Mandatory)][string]$ProcmonPath, [Parameter(Mandatory)][string]$Path,
        [Parameter(Mandatory)][string]$FilterPath, [Parameter(Mandatory)][string]$CsvPath)
    Write-Verbose "Exporting $Path to $CsvPath"
    $null = Invoke-NSPProcmonExe -FilePath $ProcmonPath -Wait -ArgumentList @('/AcceptEula', '/Quiet', '/Minimized', '/OpenLog', $Path, '/LoadConfig', $FilterPath, '/SaveApplyFilter', '/SaveAs', $CsvPath)
    if (-not (Test-Path -LiteralPath $CsvPath)) { throw "ProcMon did not write $CsvPath." }
}

function Get-NSPProcmonCoverage {
    # How many seconds a .pml actually covers: the first and last thread exit in it (they happen
    # system-wide every second, and are few enough to export quickly). ProcMon intermittently
    # writes a backing file whose index holds only its first second, and nothing else shows it -
    # seen right after another ProcMon instance exited. $null when it can't be measured.
    param([Parameter(Mandatory)][string]$ProcmonPath, [Parameter(Mandatory)][string]$Path)
    $temp = Join-Path ([IO.Path]::GetTempPath()) ("NSP_{0}" -f [guid]::NewGuid().ToString('N'))
    $null = New-Item -ItemType Directory -Path $temp -Force
    try {
        $pmc = Join-Path $temp 'coverage.pmc'
        [IO.File]::WriteAllBytes($pmc, (ConvertTo-NSPProcmonConfigBytes -Rule ([pscustomobject]@{ Column = 'Operation'; Relation = 'Is'; Value = 'Thread Exit'; Action = 'Include' })))
        $csv = Join-Path $temp 'coverage.csv'
        Export-NSPProcmonCsv -ProcmonPath $ProcmonPath -Path $Path -FilterPath $pmc -CsvPath $csv
        $rows = @(Import-Csv -LiteralPath $csv)
        if (-not $rows) { return $null }
        $first = ConvertFrom-NSPProcmonTimeOfDay $rows[0].'Time of Day'
        $last = ConvertFrom-NSPProcmonTimeOfDay $rows[-1].'Time of Day'
        if ($null -eq $first -or $null -eq $last) { return $null }
        $span = $last - $first
        if ($span -lt [TimeSpan]::Zero) { $span += [TimeSpan]::FromDays(1) }   # across midnight
        [Math]::Round($span.TotalSeconds, 1)
    } catch {
        Write-Verbose "Coverage not measured: $($_.Exception.Message)"
        return $null
    } finally {
        Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue
    }
}

function Get-NSPProcmonToolFolder {
    # Where Save-NSPProcmon puts ProcMon: the MDE work folder (Administrators and SYSTEM only) when
    # NSP.Toolkit is loaded, otherwise %ProgramData%\NSP\Tools.
    if (Get-Command Get-NSPToolWorkPath -ErrorAction SilentlyContinue) { return (Join-Path (Get-NSPToolWorkPath -Tool MDE) 'Tools') }
    return (Join-Path $env:ProgramData 'NSP\Tools')
}

# ---------------------------------------------------------------------------
function ConvertFrom-NSPProcmonTimeOfDay {
    # ProcMon's "Time of Day" column ("4:05:12.1234567 PM", or 24-hour "16:05:12.1234567") as a
    # TimeSpan with 100 ns precision; $null when it doesn't parse. Culture-independent on purpose.
    param([AllowEmptyString()][string]$Text)
    if ($Text -notmatch '^\s*(?<h>\d{1,2}):(?<m>\d{2}):(?<s>\d{2})(?:[.,](?<f>\d{1,7}))?\s*(?<ampm>[AaPp]\.?[Mm]\.?)?\s*$') { return $null }
    $h = [int]$Matches.h; $m = [int]$Matches.m; $s = [int]$Matches.s
    $fraction = [string]$Matches.f; $ampm = [string]$Matches.ampm
    if ($ampm) {
        if ($h -eq 12) { $h = 0 }
        if ($ampm.StartsWith('P', [StringComparison]::OrdinalIgnoreCase)) { $h += 12 }
    }
    $ticks = if ($fraction) { [long]($fraction.PadRight(7, '0')) } else { 0L }
    New-Object TimeSpan(([long]$h * 3600 + $m * 60 + $s) * 10000000L + $ticks)
}

function Get-NSPProcmonDetailField {
    # One "Name: value" field from ProcMon's Detail column (e.g. ShareMode, Desired Access).
    param([AllowEmptyString()][string]$Detail, [Parameter(Mandatory)][string]$Name)
    if ($Detail -match ('(?:^|,\s*)' + [regex]::Escape($Name) + ':\s*(?<v>[^,]*)')) { return $Matches.v.Trim() }
    return ''
}

function Get-NSPFileSha256 {
    param([Parameter(Mandatory)][string]$Path)
    $sha = [Security.Cryptography.SHA256]::Create()
    $stream = [IO.File]::Open($Path, 'Open', 'Read', 'ReadWrite')
    try { ([BitConverter]::ToString($sha.ComputeHash($stream)) -replace '-', '') } finally { $stream.Dispose(); $sha.Dispose() }
}