SCRIPT LIBRARY · POWERSHELL
Audit a Windows Service Across Many Computers with PowerShell
Feed it a list of computers and a service name, get back one row per machine saying whether it's installed, running, and how it starts.
- What it does
- Checks each computer in a list for a service by name or display name and reports its state, startup type, run-as account and path. Machines that don't have it, or can't be reached, still get a row, so the report covers the whole list.
- Requires
- Windows PowerShell 5.1 or PowerShell 7+
- No modules
- WinRM on the targets (or DCOM/WMI access with -Protocol Dcom)
- Permissions
- An account that can query WMI remotely on the targets. Local admin is the easy answer.
- Runs on
- Windows 10/11, Windows Server 2016+
- Tested
- Parse-checked and dry-run with mocked cmdlets in PowerShell 7.4
Part 4 of the thread PowerShell craft
"Is that agent actually running everywhere?" Every admin gets this one. The security tool, the backup client, the remote support tool somebody swears is on every desk. The console for the product says one thing, the help desk says another, and you just want a straight list.
My first go at this, years ago, was a heavy-handed thing: it used PsExec to force winrm quickconfig onto every machine in a text file on the desktop, then dug through the registry, wrote a CSV per computer and stitched them together at the end. It worked, mostly. It also changed the configuration of every machine it touched just to ask a question, which isn't a great habit.
This rewrite only reads. It opens a CIM session to each computer, asks Win32_Service about the service you care about, and returns one tidy object per machine. Machines where the service is missing, or that don't answer at all, are in the results too, because in an audit the gaps are the whole point.
<#
.SYNOPSIS
Checks a list of computers for a Windows service and reports its state, startup type and account.
.DESCRIPTION
Connects to each computer with a CIM session (WinRM by default, DCOM if you ask for it) and
looks up the service by name or display name. Every computer gets a row: found, not installed,
or unreachable, so the report accounts for the whole list. Read-only.
.PARAMETER ComputerName
Computers to check. Accepts pipeline input, so Get-Content .\computers.txt | ... works.
.PARAMETER Name
Service name or display name. Wildcards are fine, for example '*Spooler*'.
.PARAMETER Protocol
Wsman (the default, needs WinRM) or Dcom (older machines, needs RPC/WMI through the firewall).
.PARAMETER Credential
Credentials for the remote connection.
.PARAMETER CsvPath
Also save the results to this CSV file.
.EXAMPLE
Get-Content .\computers.txt | .\Get-ServiceAudit.ps1 -Name Spooler -CsvPath .\spooler.csv
#>
[CmdletBinding()]
param(
[Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName)]
[Alias('CN', 'DNSHostName')]
[string[]]$ComputerName = $env:COMPUTERNAME,
[Parameter(Mandatory)][ValidateNotNullOrEmpty()][string]$Name,
[ValidateSet('Wsman', 'Dcom')][string]$Protocol = 'Wsman',
[pscredential]$Credential,
[string]$CsvPath
)
begin {
$results = [System.Collections.Generic.List[object]]::new()
$wql = $Name.Replace('\', '\\').Replace("'", "\'").Replace('[', '[[]').Replace('_', '[_]').Replace('*', '%')
$query = "SELECT Name, DisplayName, State, StartMode, StartName, PathName FROM Win32_Service WHERE Name LIKE '$wql' OR DisplayName LIKE '$wql'"
function New-Row($Computer, $Status, $Service) {
[pscustomobject]@{
ComputerName = $Computer
Status = $Status
ServiceName = $Service.Name
DisplayName = $Service.DisplayName
State = $Service.State
StartMode = $Service.StartMode
RunAs = $Service.StartName
Path = $Service.PathName
}
}
}
process {
foreach ($computer in $ComputerName) {
$computer = $computer.Trim()
if (-not $computer -or $computer.StartsWith('#')) { continue }
$session = $null
try {
$sessionArgs = @{
ComputerName = $computer
SessionOption = New-CimSessionOption -Protocol $Protocol
ErrorAction = 'Stop'
}
if ($Credential) { $sessionArgs.Credential = $Credential }
$session = New-CimSession @sessionArgs
$services = @(Get-CimInstance -CimSession $session -Query $query -ErrorAction Stop)
if ($services.Count -eq 0) {
$results.Add((New-Row $computer 'Not installed' $null))
}
foreach ($svc in $services) {
$results.Add((New-Row $computer 'Found' $svc))
}
}
catch {
Write-Warning "$computer : $($_.Exception.Message)"
$results.Add((New-Row $computer 'Unreachable' $null))
}
finally {
if ($session) { Remove-CimSession -CimSession $session }
}
}
}
end {
if ($CsvPath) {
$results | Export-Csv -LiteralPath $CsvPath -NoTypeInformation
Write-Verbose "Saved $($results.Count) row(s) to $CsvPath"
}
$results
}
Parameters
| Parameter | Type | Default | What it's for |
|---|---|---|---|
-ComputerName | string[] | $env:COMPUTERNAME | Computers to check. Takes pipeline input, so a text file or Get-ADComputer both work. Blank lines and lines starting with |
-Name | string | — | The service name or display name. Wildcards work, so '*backup*' finds anything with backup in either name. |
-Protocol | string | Wsman | Wsman uses WinRM, which is on by default on servers. Dcom is the fallback for older or locked-down machines where WinRM isn't set up. |
-Credential | pscredential | — | Credentials for the remote connections. |
-CsvPath | string | — | Also save the full results to a CSV file. |
Run it
Check the Print Spooler on every machine in a text file, and save the results.
Get-Content .\computers.txt | .\Get-ServiceAudit.ps1 -Name Spooler -CsvPath .\spooler-audit.csvWhere is a service missing or stopped? Those are the rows you care about.
Get-Content .\computers.txt | .\Get-ServiceAudit.ps1 -Name '*Backup Agent*' | Where-Object { $_.Status -ne 'Found' -or $_.State -ne 'Running' }Every computer in an OU, over DCOM because WinRM isn't set up there yet.
Get-ADComputer -Filter * -SearchBase 'OU=Workstations,DC=contoso,DC=com' | .\Get-ServiceAudit.ps1 -Name 'wuauserv' -Protocol DcomA quick summary by status.
Get-Content .\computers.txt | .\Get-ServiceAudit.ps1 -Name Spooler | Group-Object Status, State -NoElementWhat you'll see
ComputerName Status ServiceName DisplayName State StartMode RunAs
------------ ------ ----------- ----------- ----- --------- -----
PC-0142 Found Spooler Print Spooler Running Auto LocalSystem
PC-0187 Found Spooler Print Spooler Stopped Disabled LocalSystem
PC-0203 Not installed
PC-0219 Unreachable
How it works
- Clean up the list. Names are trimmed, and blank lines or
#comments in your text file are skipped, so you can keep notes in the list itself. - Build one query. Your
-Nameis turned into a WQLLIKEpattern that checks bothNameandDisplayName.*becomes%, and characters WQL treats as wildcards (_and[) are escaped so a service likeMSSQL_Agentmatches only itself. - One CIM session per machine. Each computer gets its own
New-CimSessionover WinRM or DCOM, oneGet-CimInstancequery, and aRemove-CimSessionin afinallyblock so nothing is left hanging when a query fails. - A row for every machine. Found services get a full row. No match gets
Not installed. Any connection or query failure getsUnreachable, plus a warning with the real reason. - Return and save. Results come back as objects, and if you gave
-CsvPaththey're saved there too, all in one file.
Take it further
- Fix what you find, carefully. Pipe the stopped ones into
Invoke-Command { Start-Service -Name Spooler }against those computers. Start with-WhatIfonStart-Serviceso you see the list first. - Check the version, not just the service. The
Pathcolumn points at the executable.(Get-Item $path).VersionInfo.FileVersionon the target tells you which build is actually installed. - Make it a scheduled report. Run it weekly against an AD query instead of a text file and you'll catch new machines that came out of imaging without the agent.
Things that'll trip you up
- "Unreachable" covers a lot. Offline, DNS wrong, firewall closed, WinRM not listening, access denied. The warning printed for each one has the actual error, so read those before you go chasing machines.
- Wildcards can match more than you think. A broad pattern like '*update*' will match several services on the same machine, and you'll get a row for each. That's usually what you want in an audit, but it can surprise you in a count.
- Name versus display name. The service name is the short one (Spooler); the display name is what services.msc shows (Print Spooler). The script checks both, so either works.
- Don't fix WinRM by force. If a lot of machines come back unreachable over WinRM, turn it on properly with Group Policy or Intune rather than pushing winrm quickconfig at them from a script. In the meantime, -Protocol Dcom gets you an answer.