Wes Ellis./ a personal notebook
Technology. Stories. Side projects.
A few things worth writing down.
← Back to Script Library

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.

AT A GLANCEGet-ServiceAudit.ps1
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.

Get-ServiceAudit.ps1Download
<#
.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

ParameterTypeDefaultWhat it's for
-ComputerNamestring[]$env:COMPUTERNAMEComputers to check. Takes pipeline input, so a text file or Get-ADComputer both work. Blank lines and lines starting with
-Namestring—The service name or display name. Wildcards work, so '*backup*' finds anything with backup in either name.
-ProtocolstringWsmanWsman 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.
-Credentialpscredential—Credentials for the remote connections.
-CsvPathstring—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.csv

Where 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 Dcom

A quick summary by status.

Get-Content .\computers.txt | .\Get-ServiceAudit.ps1 -Name Spooler | Group-Object Status, State -NoElement

What you'll see

Example outputvalues are illustrative
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

  1. 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.
  2. Build one query. Your -Name is turned into a WQL LIKE pattern that checks both Name and DisplayName. * becomes %, and characters WQL treats as wildcards (_ and [) are escaped so a service like MSSQL_Agent matches only itself.
  3. One CIM session per machine. Each computer gets its own New-CimSession over WinRM or DCOM, one Get-CimInstance query, and a Remove-CimSession in a finally block so nothing is left hanging when a query fails.
  4. A row for every machine. Found services get a full row. No match gets Not installed. Any connection or query failure gets Unreachable, plus a warning with the real reason.
  5. Return and save. Results come back as objects, and if you gave -CsvPath they'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 -WhatIf on Start-Service so you see the list first.
  • Check the version, not just the service. The Path column points at the executable. (Get-Item $path).VersionInfo.FileVersion on 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.