Public/system/Stop-ProcessTree.ps1
|
#Requires -Version 5.1 function Stop-ProcessTree { <# .SYNOPSIS Terminate a process and its entire descendant tree, leaves first .DESCRIPTION Builds the descendant tree of one or more root processes from Win32_Process ParentProcessId and terminates every node, killing leaves before the root. Select roots by -Id or -Name (mutually exclusive), target local or remote machines, and preview with -WhatIf. Termination is irreversible (ConfirmImpact High). .PARAMETER ComputerName One or more computer names to target. Defaults to the local computer. Accepts pipeline input by value and by property name. .PARAMETER Credential Optional credential used to authenticate against remote computers. Ignored when the target is the local computer. .PARAMETER Id Root process ID(s) whose descendant tree is terminated. Mutually exclusive with -Name. Accepts pipeline input by property name. .PARAMETER Name Root process name(s), without the '.exe' extension. Mutually exclusive with -Id. Accepts pipeline input by property name. .EXAMPLE Stop-ProcessTree -Id 1234 Terminates process 1234 and all of its descendants on the local computer. .EXAMPLE Stop-ProcessTree -Name 'chrome' -ComputerName 'SRV01' Terminates every 'chrome' process tree found on SRV01. .EXAMPLE 'SRV01', 'SRV02' | Stop-ProcessTree -Name 'notepad' -WhatIf Previews termination of every 'notepad' process tree on SRV01 and SRV02 without actually terminating anything. .OUTPUTS PSWinOps.ProcessKillResult Returns one object per process examined (root, descendant, or unresolved root), including whether it was killed and any error encountered. .NOTES Author: Franck SALLET Version: 1.0.0 Last Modified: 2026-07-06 Requires: PowerShell 5.1+ / Windows only Requires: Sufficient privileges to terminate the target processes .LINK https://github.com/k9fr4n/PSWinOps #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High', DefaultParameterSetName = 'ById')] [OutputType('PSWinOps.ProcessKillResult')] param( [Parameter(Mandatory = $false, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [string[]]$ComputerName = @($env:COMPUTERNAME), [Parameter(Mandatory = $false)] [System.Management.Automation.PSCredential]$Credential, [Parameter(Mandatory = $true, ParameterSetName = 'ById', ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [int[]]$Id, [Parameter(Mandatory = $true, ParameterSetName = 'ByName', ValueFromPipelineByPropertyName = $true)] [ValidateNotNullOrEmpty()] [string[]]$Name ) begin { Write-Verbose -Message "[$($MyInvocation.MyCommand)] Starting" # --- Protected system PIDs never eligible for termination -------------- $protectedPids = @(0, 4) $maxDepth = 100 # --- Scriptblocks executed locally or remotely via Invoke-RemoteOrLocal $queryScriptBlock = { Get-CimInstance -ClassName 'Win32_Process' -ErrorAction Stop | Select-Object -Property ProcessId, ParentProcessId, Name } $terminateScriptBlock = { param($targetPid) $targetProcess = Get-CimInstance -ClassName 'Win32_Process' -Filter "ProcessId=$targetPid" -ErrorAction Stop if (-not $targetProcess) { throw "Process $targetPid no longer exists" } Invoke-CimMethod -InputObject $targetProcess -MethodName 'Terminate' -ErrorAction Stop } } process { foreach ($targetComputer in $ComputerName) { Write-Verbose -Message "[$($MyInvocation.MyCommand)] Processing $targetComputer" try { $processList = Invoke-RemoteOrLocal -ComputerName $targetComputer -Credential $Credential -ScriptBlock $queryScriptBlock # =============================================================== # RESOLVE ROOT PROCESSES # =============================================================== $rootProcesses = [System.Collections.Generic.List[object]]::new() $unresolvedRows = [System.Collections.Generic.List[object]]::new() if ($PSCmdlet.ParameterSetName -eq 'ById') { foreach ($rootId in $Id) { $match = $processList | Where-Object -Property ProcessId -eq $rootId if ($match) { $rootProcesses.Add($match) } else { $unresolvedRows.Add([PSCustomObject]@{ PSTypeName = 'PSWinOps.ProcessKillResult' ComputerName = $targetComputer ProcessId = $rootId Name = $null ParentProcessId = $null Depth = 0 Killed = $false Error = 'Process not found' Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' }) } } } else { foreach ($rootName in $Name) { $expectedName = if ($rootName -like '*.exe') { $rootName } else { "$rootName.exe" } $nameMatches = $processList | Where-Object -Property Name -eq $expectedName if ($nameMatches) { foreach ($match in $nameMatches) { $rootProcesses.Add($match) } } else { $unresolvedRows.Add([PSCustomObject]@{ PSTypeName = 'PSWinOps.ProcessKillResult' ComputerName = $targetComputer ProcessId = $null Name = $rootName ParentProcessId = $null Depth = 0 Killed = $false Error = 'Process not found' Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' }) } } } foreach ($unresolvedRow in $unresolvedRows) { $unresolvedRow } # =============================================================== # BUILD DESCENDANT TREE (breadth-first) AND KILL LEAVES FIRST # =============================================================== foreach ($rootProcess in $rootProcesses) { $treeNodes = [System.Collections.Generic.List[object]]::new() $visitedPids = [System.Collections.Generic.HashSet[int]]::new() $queue = [System.Collections.Generic.Queue[object]]::new() $null = $visitedPids.Add([int]$rootProcess.ProcessId) $queue.Enqueue([PSCustomObject]@{ Process = $rootProcess; Depth = 0 }) while ($queue.Count -gt 0) { $current = $queue.Dequeue() $treeNodes.Add($current) if ($current.Depth -ge $maxDepth) { Write-Verbose -Message "[$($MyInvocation.MyCommand)] Depth bound ($maxDepth) reached at PID $($current.Process.ProcessId), stopping descent" continue } # Never descend into a protected system process: its descendants # are core kernel/session processes and terminating them would # crash the machine. The protected node itself is still emitted # as a refused row by the kill loop below. if ([int]$current.Process.ProcessId -in $protectedPids) { Write-Verbose -Message "[$($MyInvocation.MyCommand)] Protected PID $($current.Process.ProcessId) reached, not descending into its children" continue } $children = $processList | Where-Object -Property ParentProcessId -eq $current.Process.ProcessId foreach ($child in $children) { $childPid = [int]$child.ProcessId if (-not $visitedPids.Contains($childPid)) { $null = $visitedPids.Add($childPid) $queue.Enqueue([PSCustomObject]@{ Process = $child; Depth = $current.Depth + 1 }) } } } # Leaves first: deepest nodes terminated before their ancestors. $killOrder = $treeNodes | Sort-Object -Property Depth -Descending foreach ($node in $killOrder) { $nodePid = [int]$node.Process.ProcessId $nodeName = $node.Process.Name $nodeParentPid = $node.Process.ParentProcessId if ($nodePid -in $protectedPids) { [PSCustomObject]@{ PSTypeName = 'PSWinOps.ProcessKillResult' ComputerName = $targetComputer ProcessId = $nodePid Name = $nodeName ParentProcessId = $nodeParentPid Depth = $node.Depth Killed = $false Error = 'Refused: protected system process' Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' } continue } if ($PSCmdlet.ShouldProcess("$targetComputer PID $nodePid ($nodeName)", 'Terminate process')) { $killed = $false $killError = $null try { $terminateResult = Invoke-RemoteOrLocal -ComputerName $targetComputer -Credential $Credential -ScriptBlock $terminateScriptBlock -ArgumentList @($nodePid) if ($terminateResult.ReturnValue -eq 0) { $killed = $true } else { $killError = "Terminate returned code $($terminateResult.ReturnValue)" } } catch { $killError = $_.Exception.Message } [PSCustomObject]@{ PSTypeName = 'PSWinOps.ProcessKillResult' ComputerName = $targetComputer ProcessId = $nodePid Name = $nodeName ParentProcessId = $nodeParentPid Depth = $node.Depth Killed = $killed Error = $killError Timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' } } } } } catch { Write-Error -Message "[$($MyInvocation.MyCommand)] Failed on '$targetComputer': $_" continue } } } end { Write-Verbose -Message "[$($MyInvocation.MyCommand)] Completed" } } |