~/2025/08/27/powershell-windows-inventory-installed-software-across-a-domain.md

PowerShell: Windows – Inventory Installed Software Across a Domain

---
author: 
date: 
read: 3 min
in:   [ps, scripts]
tags: [powershell, windows, discovery, active-directory]
---

$ grep -n '^#' post.md

Software inventory questions come up constantly: which machines still have an old Java runtime, who has a vulnerable version of some agent installed, how many endpoints are actually running the app a vendor wants to renew. The query everyone reaches for first, Get-WmiObject Win32_Product (or Get-CimInstance), is the one not to run fleet-wide. Microsoft's support article on it (KB 974524) explains that enumerating Win32_Product goes through the MSI provider and "starts a consistency check of packages installed, verifying, and repairing the install". The visible symptom is an MsiInstaller event 1035, "Windows Installer reconfigured the product", logged for every installed MSI, plus slow startups and sign-ins if a GPO WMI filter does it. So I read the same Uninstall registry keys that Programs and Features reads, in parallel, across every machine on the list.

Requirements

  • Windows PowerShell 5.1 or PowerShell 7 on the machine running the script.
  • PowerShell remoting (WinRM) enabled and reachable on every target. Windows Server 2012 and later have it on by default; Windows client editions don't, so enable it with Enable-PSRemoting or the equivalent Group Policy (WinRM service, listener, and firewall rule).
  • An account in the Administrators or Remote Management Users group on each target. The default Microsoft.PowerShell endpoint only admits Administrators unless you've changed its security descriptor.
  • The Active Directory module (RSAT) only if you want to source the computer list from AD.

Parameters

NameTypeRequiredDescription
-ComputerNameStringNoOne or more computer names to inventory. If omitted, -ComputerListPath is used instead.
-ComputerListPathStringNoPath to a text file with one computer name per line.
-OutputPathStringNoPath to the consolidated CSV. Defaults to .\software-inventory.csv.
-ThrottleLimitIntNoMaximum number of concurrent remote connections. Defaults to 32, the same default Invoke-Command uses.

Usage

Inventory a short list of computers directly:

powershell
.\Get-SoftwareInventory.ps1 -ComputerName SRV-APP01, SRV-APP02, SRV-SQL01

Inventory every computer name in a text file and write the results somewhere specific:

powershell
.\Get-SoftwareInventory.ps1 -ComputerListPath C:\Lists\workstations.txt -OutputPath C:\Reports\software-inventory.csv

Pull enabled computer accounts from Active Directory and feed them in directly:

powershell
$computers = Get-ADComputer -Filter 'Enabled -eq $true' | Select-Object -ExpandProperty Name
.\Get-SoftwareInventory.ps1 -ComputerName $computers -ThrottleLimit 64

Sample console output:

text
Querying 3 computer(s) with a throttle limit of 32 ...
Completed SRV-APP01 (231 entries)
Completed SRV-APP02 (187 entries)
WARNING: Failed SRV-SQL01: Connecting to remote server SRV-SQL01 failed with the following error message : WinRM cannot process the request...
Inventory written to .\software-inventory.csv (418 rows from 2 of 3 computers)
WARNING: Unreachable or failed computers: SRV-SQL01

Answer the common questions from the CSV afterwards, for example every machine with a Java runtime and its version:

powershell
Import-Csv -Path .\software-inventory.csv |
    Where-Object { $_.DisplayName -like "*Java*" } |
    Sort-Object -Property ComputerName |
    Format-Table ComputerName, DisplayName, DisplayVersion, Scope -AutoSize

Script

powershell
<#
.SYNOPSIS
    Remotely enumerates installed software from the registry across a list of
    Windows computers and consolidates the results into one CSV.
.DESCRIPTION
    Reads the 64-bit and 32-bit (WOW6432Node) Uninstall keys under HKLM, plus the
    Uninstall key of every user hive currently loaded under HKEY_USERS, on each target
    over PowerShell remoting. One Invoke-Command call fans out to all computers; each
    computer runs as a child job, so failures are reported per computer. Never queries
    Win32_Product.
.PARAMETER ComputerName
    One or more computer names to inventory.
.PARAMETER ComputerListPath
    Path to a text file with one computer name per line, used when -ComputerName
    is not supplied.
.PARAMETER OutputPath
    Path to the consolidated CSV. Defaults to .\software-inventory.csv.
.PARAMETER ThrottleLimit
    Maximum number of concurrent remote connections. Defaults to 32.
.EXAMPLE
    .\Get-SoftwareInventory.ps1 -ComputerListPath C:\Lists\workstations.txt
.NOTES
    Author  : Thomas Lasswell (https://www.techcolumnist.com)
    Version : 1.0 (2025-08-27)
    Requires: PowerShell remoting (WinRM) on every target; Administrators or
              Remote Management Users membership on each target
#>
[CmdletBinding()]
param(
    [string[]]$ComputerName,

    [string]$ComputerListPath,

    [string]$OutputPath = ".\software-inventory.csv",

    [int]$ThrottleLimit = 32
)

if (-not $ComputerName) {
    if (-not $ComputerListPath -or -not (Test-Path -Path $ComputerListPath)) {
        throw "Supply -ComputerName or a valid -ComputerListPath."
    }

    $ComputerName = Get-Content -Path $ComputerListPath | Where-Object { $_.Trim() -ne "" } | ForEach-Object { $_.Trim() }
}

$ComputerName = @($ComputerName | Sort-Object -Unique)
if ($ComputerName.Count -eq 0) {
    throw "No computer names to query. Check -ComputerName or the contents of -ComputerListPath."
}
Write-Host "Querying $($ComputerName.Count) computer(s) with a throttle limit of $ThrottleLimit ..."

# The script block that runs on each remote computer.
$inventoryScript = {
    $sources = @(
        @{ Scope = "Machine64"; Path = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*" },
        @{ Scope = "Machine32"; Path = "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*" },
        @{ Scope = "User"; Path = "Registry::HKEY_USERS\*\Software\Microsoft\Windows\CurrentVersion\Uninstall\*" }
    )

    foreach ($source in $sources) {
        Get-ItemProperty -Path $source.Path -ErrorAction SilentlyContinue |
            Where-Object { $_.DisplayName } |
            ForEach-Object {
                [pscustomobject]@{
                    ComputerName    = $env:COMPUTERNAME
                    Scope           = $source.Scope
                    DisplayName     = $_.DisplayName
                    DisplayVersion  = $_.DisplayVersion
                    Publisher       = $_.Publisher
                    InstallDate     = $_.InstallDate
                    SystemComponent = $_.SystemComponent
                    KeyName         = $_.PSChildName
                }
            }
    }
}

$parentJob = Invoke-Command -ComputerName $ComputerName -ScriptBlock $inventoryScript -AsJob -ThrottleLimit $ThrottleLimit
$parentJob | Wait-Job | Out-Null

$results = New-Object System.Collections.Generic.List[object]
$failures = New-Object System.Collections.Generic.List[string]

# One child job per computer: read each separately so one failure doesn't hide the rest.
foreach ($child in $parentJob.ChildJobs) {
    $target = $child.Location

    if ($child.State -eq "Failed") {
        $reason = $child.JobStateInfo.Reason.Message
        $failures.Add($target)
        Write-Warning "Failed $target`: $reason"
        continue
    }

    $rows = @(Receive-Job -Job $child -ErrorAction SilentlyContinue |
        Select-Object -Property ComputerName, Scope, DisplayName, DisplayVersion, Publisher, InstallDate, SystemComponent, KeyName)
    $results.AddRange([object[]]$rows)
    Write-Host "Completed $target ($($rows.Count) entries)"
}

Remove-Job -Job $parentJob -Force

$results | Export-Csv -Path $OutputPath -NoTypeInformation

$succeeded = $ComputerName.Count - $failures.Count
Write-Host "Inventory written to $OutputPath ($($results.Count) rows from $succeeded of $($ComputerName.Count) computers)"

if ($failures.Count -gt 0) {
    Write-Warning "Unreachable or failed computers: $($failures -join ', ')"
}

Notes

  • Why not Win32_Product: per KB 974524 the query isn't optimized, enumerates every product through msiprov.dll, and triggers a consistency check and repair of installed packages. Microsoft's suggested lighter alternatives are Win32reg_AddRemovePrograms (only present when the Configuration Manager client is installed) or the StdRegProv registry provider. Reading the registry over remoting, as here, is the same idea.
  • The User scope only sees hives that are loaded, which in practice means users currently signed in (plus service profiles). Per-user installers that write to HKCU for someone who isn't signed in won't show up. If that matters, run the collection as a sign-in or scheduled task rather than a sweep.
  • The registry approach misses software that doesn't write an Uninstall key: portable apps and MSIX/Store packages. For packaged apps, run Get-AppxPackage -AllUsers in a second pass.
  • InstallDate is less useful than it looks. Microsoft's Windows Installer documentation defines it as "the last time this product received service", replaced each time a patch is applied or removed or the product is repaired. Many non-MSI installers don't set it at all. Treat it as a hint.
  • SystemComponent is exported rather than filtered. An installer sets it (via the MSI ARPSYSTEMCOMPONENT property) to hide the entry from Add or Remove Programs. Filter on it in the report if you want output that matches what users see; keep it if you're hunting for agents and runtimes.
  • The throttle applies to the single Invoke-Command call: at most -ThrottleLimit connections are open at once, and the rest queue. The earlier pattern of calling Invoke-Command -AsJob once per computer in a loop doesn't throttle anything, because each call only has one computer.
  • This pairs with the local admin audit script, and both feed the approach in A Discovery Inventory Nobody Maintains by Hand.

Source