Created
April 25, 2026 12:08
-
-
Save anthonybaldwin/aba2801f3344e3713b16711517f0d851 to your computer and use it in GitHub Desktop.
procwatch — Windows / PowerShell 7 new-process watcher with ancestor tracing, env/CWD capture (PEB read), and rotating logs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # procwatch.ps1 - lightweight new-process watcher for Windows / PowerShell 7 | |
| # | |
| # Drop into your $PROFILE (or dot-source on demand) and call procwatch. | |
| # Captures: PID/PPID, image path, command line, owner, session id, working | |
| # directory, full environment (PEB read), and parent chain. Optional rotating | |
| # file logs. | |
| # | |
| # Examples: | |
| # procwatch # watch only, console output | |
| # procwatch -Trace # also walk parent chain | |
| # procwatch -Trace -Log # tee to ~/.procwatch/procwatch-<ts>.log | |
| # procwatch -Trace -Log -Rotate # 25 MB per file, keep 20 files, auto-roll | |
| # procwatch -Trace -NoEnv # skip env/CWD capture (faster, fewer perms) | |
| # | |
| # Env/CWD capture uses NtQueryInformationProcess + ReadProcessMemory to walk | |
| # the target''s PEB. 64-bit targets only. Higher-integrity / SYSTEM processes | |
| # need an elevated shell to read. | |
| function Watch-NewProcesses { | |
| <# | |
| .SYNOPSIS | |
| Lightweight new-process watcher with ancestor tracing, env/CWD capture, and rotating logs. | |
| .PARAMETER Trace | |
| Walk and print the parent chain on each new process. | |
| .PARAMETER Log / -LogPath | |
| Mirror output to a UTF-8 file. -Log auto-names under ~/.procwatch/. | |
| .PARAMETER Rotate | |
| Rotate to a new file when -MaxBytes or -MaxEvents is hit. Default cap: 25 MB / 20 files. | |
| .PARAMETER MaxBytes / -MaxEvents / -MaxFiles | |
| Per-file caps and history depth. Without -Rotate, hitting a cap stops logging. | |
| .PARAMETER NoEnv | |
| Skip environment + working-directory capture (PEB read). Faster; fewer permissions issues. | |
| .NOTES | |
| Env/CWD capture uses NtQueryInformationProcess + ReadProcessMemory (PEB walk). 64-bit | |
| targets only. System / higher-integrity processes will skip silently without admin. | |
| #> | |
| [CmdletBinding()] | |
| param( | |
| [switch]$Trace, | |
| [switch]$Log, | |
| [string]$LogPath, | |
| [int64]$MaxBytes = 0, | |
| [int]$MaxEvents = 0, | |
| [switch]$Rotate, | |
| [int]$MaxFiles = 0, | |
| [switch]$NoEnv | |
| ) | |
| if (-not ('ProcPeb' -as [type])) { | |
| Add-Type -Language CSharp -TypeDefinition @' | |
| using System; | |
| using System.Runtime.InteropServices; | |
| using System.Text; | |
| using System.Collections.Generic; | |
| public static class ProcPeb { | |
| [DllImport("kernel32.dll", SetLastError = true)] | |
| public static extern IntPtr OpenProcess(uint a, bool i, int p); | |
| [DllImport("kernel32.dll", SetLastError = true)] | |
| public static extern bool CloseHandle(IntPtr h); | |
| [DllImport("kernel32.dll", SetLastError = true)] | |
| public static extern bool ReadProcessMemory(IntPtr h, IntPtr a, byte[] b, IntPtr n, out IntPtr r); | |
| [DllImport("kernel32.dll")] | |
| public static extern bool IsWow64Process(IntPtr h, out bool w); | |
| [DllImport("ntdll.dll")] | |
| public static extern int NtQueryInformationProcess(IntPtr h, int c, ref PBI p, int l, ref int r); | |
| [StructLayout(LayoutKind.Sequential)] | |
| public struct PBI { | |
| public IntPtr R1; | |
| public IntPtr PebBase; | |
| public IntPtr R2a; | |
| public IntPtr R2b; | |
| public IntPtr Pid; | |
| public IntPtr R3; | |
| } | |
| public class Info { public string Cwd; public string[] Env; public string Err; } | |
| public static Info Read(int pid) { | |
| var info = new Info(); | |
| IntPtr h = OpenProcess(0x1010, false, pid); | |
| if (h == IntPtr.Zero) { info.Err = "OpenProcess err " + Marshal.GetLastWin32Error(); return info; } | |
| try { | |
| bool wow; | |
| if (IsWow64Process(h, out wow) && wow) { info.Err = "wow64"; return info; } | |
| var pbi = new PBI(); int rl = 0; | |
| int st = NtQueryInformationProcess(h, 0, ref pbi, Marshal.SizeOf<PBI>(), ref rl); | |
| if (st != 0 || pbi.PebBase == IntPtr.Zero) { info.Err = "ntqip"; return info; } | |
| byte[] peb = new byte[0x28]; IntPtr got; | |
| if (!ReadProcessMemory(h, pbi.PebBase, peb, (IntPtr)peb.Length, out got)) { info.Err = "rpm peb"; return info; } | |
| long pp = BitConverter.ToInt64(peb, 0x20); | |
| if (pp == 0) { info.Err = "no params"; return info; } | |
| byte[] pb = new byte[0x88]; | |
| if (!ReadProcessMemory(h, (IntPtr)pp, pb, (IntPtr)pb.Length, out got)) { info.Err = "rpm params"; return info; } | |
| ushort cwdLen = BitConverter.ToUInt16(pb, 0x38); | |
| long cwdBuf = BitConverter.ToInt64(pb, 0x40); | |
| if (cwdLen > 0 && cwdBuf != 0) { | |
| byte[] cb = new byte[cwdLen]; | |
| if (ReadProcessMemory(h, (IntPtr)cwdBuf, cb, (IntPtr)cb.Length, out got)) { | |
| info.Cwd = Encoding.Unicode.GetString(cb).TrimEnd('\\', '\0'); | |
| } | |
| } | |
| long envAddr = BitConverter.ToInt64(pb, 0x80); | |
| if (envAddr != 0) { | |
| var buf = new List<byte>(); | |
| for (int off = 0; off < 256 * 1024; off += 4096) { | |
| byte[] page = new byte[4096]; | |
| IntPtr pr; | |
| bool ok = ReadProcessMemory(h, (IntPtr)(envAddr + off), page, (IntPtr)page.Length, out pr); | |
| int read = pr.ToInt32(); | |
| if (read <= 0) break; | |
| for (int j = 0; j < read; j++) buf.Add(page[j]); | |
| if (!ok || read < 4096) break; | |
| } | |
| if (buf.Count > 0) { | |
| byte[] eb = buf.ToArray(); | |
| int rd = eb.Length; | |
| int end = rd - (rd % 2); | |
| for (int i = 0; i + 3 < rd; i += 2) { | |
| if (eb[i] == 0 && eb[i+1] == 0 && eb[i+2] == 0 && eb[i+3] == 0) { end = i; break; } | |
| } | |
| string raw = Encoding.Unicode.GetString(eb, 0, end); | |
| var list = new List<string>(); | |
| foreach (string s in raw.Split('\0')) { | |
| if (!string.IsNullOrEmpty(s) && !s.StartsWith("=")) list.Add(s); | |
| } | |
| info.Env = list.ToArray(); | |
| } | |
| } | |
| } finally { CloseHandle(h); } | |
| return info; | |
| } | |
| } | |
| '@ | |
| } | |
| if ($Rotate) { | |
| if ($MaxBytes -le 0) { $MaxBytes = 25MB } | |
| if ($MaxFiles -le 0) { $MaxFiles = 20 } | |
| } | |
| $state = @{ | |
| LogFile = $null | |
| FileEventCount = 0 | |
| TotalEventCount = 0 | |
| Capped = $false | |
| LogIndex = 1 | |
| LogBaseDir = $null | |
| LogBaseName = $null | |
| LogBaseExt = '.log' | |
| } | |
| if ($Log -or $LogPath -or $Rotate) { | |
| if (-not $LogPath) { | |
| $logDir = Join-Path $HOME '.procwatch' | |
| if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir | Out-Null } | |
| $state.LogBaseDir = $logDir | |
| $state.LogBaseName = "procwatch-{0}" -f (Get-Date -Format 'yyyyMMdd-HHmmss') | |
| } else { | |
| $state.LogBaseDir = Split-Path $LogPath -Parent | |
| if (-not $state.LogBaseDir) { $state.LogBaseDir = (Get-Location).Path } | |
| $state.LogBaseName = [IO.Path]::GetFileNameWithoutExtension($LogPath) | |
| $ext = [IO.Path]::GetExtension($LogPath); if (-not $ext) { $ext = '.log' } | |
| $state.LogBaseExt = $ext | |
| } | |
| $LogPath = if ($Rotate) { | |
| Join-Path $state.LogBaseDir ("{0}-{1:D3}{2}" -f $state.LogBaseName, $state.LogIndex, $state.LogBaseExt) | |
| } else { | |
| Join-Path $state.LogBaseDir ("{0}{1}" -f $state.LogBaseName, $state.LogBaseExt) | |
| } | |
| New-Item -ItemType File -Path $LogPath -Force | Out-Null | |
| $state.LogFile = $LogPath | |
| } | |
| $lines = [System.Collections.Generic.List[string]]::new() | |
| $say = { | |
| param([string]$t, [ConsoleColor]$c = 'White') | |
| Write-Host $t -ForegroundColor $c | |
| $lines.Add($t) | Out-Null | |
| } | |
| $row = { | |
| param([string]$l, [string]$v, [ConsoleColor]$lc = 'DarkGray', [ConsoleColor]$vc = 'Gray') | |
| Write-Host $l -NoNewline -ForegroundColor $lc | |
| Write-Host $v -ForegroundColor $vc | |
| $lines.Add($l + $v) | Out-Null | |
| } | |
| $blank = { Write-Host ''; $lines.Add('') | Out-Null } | |
| $flush = { | |
| if ($state.LogFile) { | |
| Add-Content -Path $state.LogFile -Value $lines -Encoding utf8NoBOM | |
| } | |
| $lines.Clear() | |
| } | |
| $rotateNow = { | |
| $state.LogIndex++ | |
| $next = Join-Path $state.LogBaseDir ("{0}-{1:D3}{2}" -f $state.LogBaseName, $state.LogIndex, $state.LogBaseExt) | |
| New-Item -ItemType File -Path $next -Force | Out-Null | |
| Write-Host ("[procwatch] rotating -> {0}" -f $next) -ForegroundColor DarkYellow | |
| $state.LogFile = $next | |
| $state.FileEventCount = 0 | |
| if ($MaxFiles -gt 0) { | |
| $pattern = "{0}-*{1}" -f $state.LogBaseName, $state.LogBaseExt | |
| $existing = @(Get-ChildItem -LiteralPath $state.LogBaseDir -Filter $pattern -ErrorAction SilentlyContinue | Sort-Object Name) | |
| while ($existing.Count -gt $MaxFiles) { | |
| try { Remove-Item -LiteralPath $existing[0].FullName -Force -ErrorAction Stop } catch {} | |
| $existing = @($existing | Select-Object -Skip 1) | |
| } | |
| } | |
| } | |
| $seen = @{} | |
| $cache = @{} | |
| $snapBasic = { | |
| param($p) | |
| [pscustomobject]@{ | |
| Name = $p.Name | |
| Path = $p.ExecutablePath | |
| Cmd = $p.CommandLine | |
| PPID = $p.ParentProcessId | |
| Created = $p.CreationDate | |
| SessionId = $p.SessionId | |
| Owner = $null | |
| Cwd = $null | |
| Env = $null | |
| } | |
| } | |
| $snapFull = { | |
| param($p) | |
| $info = & $snapBasic $p | |
| try { | |
| $o = $p | Invoke-CimMethod -MethodName GetOwner -ErrorAction Stop | |
| if ($o -and $o.ReturnValue -eq 0) { | |
| $info.Owner = if ($o.Domain) { "$($o.Domain)\$($o.User)" } else { $o.User } | |
| } | |
| } catch {} | |
| if (-not $NoEnv) { | |
| try { | |
| $peb = [ProcPeb]::Read([int]$p.ProcessId) | |
| if ($peb -and -not $peb.Err) { | |
| $info.Cwd = $peb.Cwd | |
| $info.Env = $peb.Env | |
| } | |
| } catch {} | |
| } | |
| $info | |
| } | |
| Get-CimInstance Win32_Process | ForEach-Object { | |
| $seen[$_.ProcessId] = 1 | |
| $cache[$_.ProcessId] = & $snapBasic $_ | |
| } | |
| & $say 'Watching new processes (Ctrl-C to stop)' Yellow | |
| if ($state.LogFile) { | |
| & $say ("Logging to: {0}" -f $state.LogFile) DarkYellow | |
| if ($Rotate) { & $say (" rotate every {0:N0} bytes, keep {1} files" -f $MaxBytes, $MaxFiles) DarkGray } | |
| elseif ($MaxBytes -gt 0) { & $say (" cap: {0:N0} bytes (then stop)" -f $MaxBytes) DarkGray } | |
| if ($MaxEvents -gt 0) { & $say (" cap: {0} events per file" -f $MaxEvents) DarkGray } | |
| if ($NoEnv) { & $say " -NoEnv: env/CWD capture disabled" DarkGray } | |
| } | |
| & $flush | |
| $sessionRoots = @{ | |
| 'explorer.exe' = 1; 'wininit.exe' = 1; 'services.exe' = 1 | |
| 'csrss.exe' = 1; 'smss.exe' = 1; 'lsass.exe' = 1 | |
| 'winlogon.exe' = 1; 'userinit.exe' = 1; 'System' = 1 | |
| } | |
| while ($true) { | |
| $current = Get-CimInstance Win32_Process | |
| foreach ($p in $current) { | |
| if (-not $cache.ContainsKey($p.ProcessId)) { $cache[$p.ProcessId] = & $snapFull $p } | |
| } | |
| $current | Where-Object { -not $seen[$_.ProcessId] } | ForEach-Object { | |
| $seen[$_.ProcessId] = 1 | |
| $info = $cache[$_.ProcessId] | |
| $ts = Get-Date -Format 'HH:mm:ss.fff' | |
| & $blank | |
| & $say ("──[ {0} ]──────────────────────────────────────────" -f $ts) DarkCyan | |
| & $say (" PID : {0}" -f $_.ProcessId) White | |
| & $say (" PPID : {0}" -f $_.ParentProcessId) DarkGray | |
| & $say (" Name : {0}" -f $info.Name) Green | |
| if ($info.Path) { & $row " Path : " $info.Path DarkGray Gray } | |
| if ($info.Created) { & $row " Start : " ($info.Created.ToString('HH:mm:ss.fff')) DarkGray Gray } | |
| if ($null -ne $info.SessionId) { & $row " Sess : " ([string]$info.SessionId) DarkGray Gray } | |
| if ($info.Owner) { & $row " User : " $info.Owner DarkGray Gray } | |
| if ($info.Cwd) { & $row " CWD : " $info.Cwd DarkGray Gray } | |
| if ($info.Cmd) { | |
| & $blank | |
| & $say ' Cmd:' DarkGray | |
| & $say (" {0}" -f $info.Cmd) Cyan | |
| } | |
| if ($info.Env -and $info.Env.Count -gt 0) { | |
| & $blank | |
| & $say (" Env ({0} vars):" -f $info.Env.Count) DarkGray | |
| foreach ($e in $info.Env) { & $say (" {0}" -f $e) DarkGray } | |
| } | |
| if ($Trace) { | |
| & $blank | |
| & $say ' Ancestors:' DarkYellow | |
| $cur = $_.ParentProcessId | |
| $depth = 1 | |
| $lastName = $null | |
| while ($cur -and $depth -le 12) { | |
| if ($cache.ContainsKey($cur)) { | |
| $a = $cache[$cur] | |
| $lastName = $a.Name | |
| & $blank | |
| & $say (" [{0}] PID : {1}" -f $depth, $cur) White | |
| & $say (" Name : {0}" -f $a.Name) Green | |
| if ($a.Path) { & $row " Path : " $a.Path DarkGray Gray } | |
| if ($a.Created) { & $row " Start: " ($a.Created.ToString('HH:mm:ss.fff')) DarkGray Gray } | |
| if ($null -ne $a.SessionId) { & $row " Sess : " ([string]$a.SessionId) DarkGray Gray } | |
| if ($a.Owner) { & $row " User : " $a.Owner DarkGray Gray } | |
| if ($a.Cwd) { & $row " CWD : " $a.Cwd DarkGray Gray } | |
| if ($a.Cmd) { | |
| & $blank | |
| & $say ' Cmd:' DarkGray | |
| & $say (" {0}" -f $a.Cmd) Cyan | |
| } | |
| if ($a.Env -and $a.Env.Count -gt 0) { | |
| & $blank | |
| & $say (" Env ({0} vars):" -f $a.Env.Count) DarkGray | |
| foreach ($e in $a.Env) { & $say (" {0}" -f $e) DarkGray } | |
| } | |
| $cur = $a.PPID | |
| $depth++ | |
| } else { | |
| & $blank | |
| if ($lastName -and $sessionRoots[$lastName]) { | |
| & $say (" (session root — parent PID {0} exited at login/boot)" -f $cur) DarkGray | |
| } else { | |
| & $say (" [{0}] PID : {1} (gone — PID may be recycled)" -f $depth, $cur) DarkRed | |
| } | |
| break | |
| } | |
| } | |
| } | |
| & $flush | |
| $state.FileEventCount++ | |
| $state.TotalEventCount++ | |
| if ($state.LogFile -and -not $state.Capped) { | |
| $hit = $false | |
| if ($MaxBytes -gt 0 -and (Get-Item $state.LogFile).Length -ge $MaxBytes) { $hit = $true } | |
| if ($MaxEvents -gt 0 -and $state.FileEventCount -ge $MaxEvents) { $hit = $true } | |
| if ($hit) { | |
| if ($Rotate) { | |
| & $rotateNow | |
| } else { | |
| Write-Host ("[procwatch] log cap reached; logging disabled. Path: {0}" -f $state.LogFile) -ForegroundColor Red | |
| $state.Capped = $true | |
| $state.LogFile = $null | |
| } | |
| } | |
| } | |
| } | |
| Start-Sleep -Milliseconds 200 | |
| } | |
| } | |
| Set-Alias -Name procwatch -Value Watch-NewProcesses |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment