Skip to content

Instantly share code, notes, and snippets.

@anthonybaldwin
Created April 25, 2026 12:08
Show Gist options
  • Select an option

  • Save anthonybaldwin/aba2801f3344e3713b16711517f0d851 to your computer and use it in GitHub Desktop.

Select an option

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
# 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