PowerShell Software License Compliance Audit Script

Keeping software license compliance across hundreds of Windows machines is only feasible with automation. Get-Package misses many installations, and querying Win32_Product silently triggers MSI repair operations on every machine you touch. The correct approach is to read the Windows registry directly — it is the authoritative, low-side-effect source of installed software data. This guide builds a full compliance script: registry enumeration, remote parallel execution, allowlist comparison, and per-machine CSV reporting.
Quick Answer
Read HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall and the Wow6432Node sub-key for 32-bit apps, compare results against an approved-software CSV, and flag anything outside the list. Run across your fleet with Invoke-Command and ForEach-Object -Parallel (PowerShell 7+).
Reading Installed Software from HKLM and HKCU Registry Hives
Installed software is recorded in two root hives. HKLM covers machine-wide installs and requires administrator rights to read; HKCU covers per-user installs and only requires user rights on the current session. Missing either hive produces an incomplete inventory. On 64-bit Windows you also need the Wow6432Node key for 32-bit applications installed under SysWOW64.
function Get-InstalledSoftware {
$uninstallPaths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*"
)
foreach ($path in $uninstallPaths) {
if (Test-Path $path) {
Get-ItemProperty -Path $path -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName -and $_.DisplayName -ne "" } |
Select-Object DisplayName, DisplayVersion, Publisher,
InstallDate, @{N="Hive";E={ $path.Split(":")[0] }}
}
}
}
Get-InstalledSoftware | Sort-Object DisplayName
The -ErrorAction SilentlyContinue suppresses errors on registry keys that exist but have no readable properties, which is common with legacy MSI remnants.
Querying Win32_Product Alternatives for Accuracy
Never use Get-WmiObject Win32_Product or Get-CimInstance Win32_Product in production audits. Both trigger an MSI consistency check on every installed package during enumeration, which can take minutes and silently repair or reconfigure software mid-audit. The registry approach above is faster, side-effect-free, and captures the same information. If you need MSI-specific metadata such as product codes, query HKLM:\SOFTWARE\Classes\Installer\Products instead.
Running the Inventory Across Remote Machines in Parallel
With PowerShell 7 you can use ForEach-Object -Parallel to query multiple machines simultaneously. The $using: scope modifier passes local variables into the parallel scriptblock.
$computers = Get-Content "F:\audit\computers.txt"
$results = $computers | ForEach-Object -Parallel {
$pc = $_
try {
$software = Invoke-Command -ComputerName $pc -ErrorAction Stop -ScriptBlock {
$paths = @(
"HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*",
"HKLM:\SOFTWARE\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"
)
foreach ($p in $paths) {
if (Test-Path $p) {
Get-ItemProperty $p -ErrorAction SilentlyContinue |
Where-Object { $_.DisplayName } |
Select-Object DisplayName, DisplayVersion, Publisher
}
}
}
$software | Select-Object *, @{N="ComputerName";E={ $pc }}
} catch {
[PSCustomObject]@{ ComputerName = $pc; Error = $_.Exception.Message }
}
} -ThrottleLimit 20
$results
Set -ThrottleLimit based on your network capacity — 20 is a safe starting point for most environments running over a LAN.
Comparing Results Against an Approved Software Allowlist
The allowlist is a CSV with at minimum a DisplayName column. You can add an optional MaxVersion column to flag outdated but previously approved versions. Load it once before the comparison loop to avoid repeated disk reads.
$allowlist = Import-Csv "F:\audit\approved-software.csv"
$approvedNames = $allowlist.DisplayName | ForEach-Object { $_.Trim().ToLower() }
$violations = $results | Where-Object { -not $_.Error } | ForEach-Object {
$app = $_
$normalized = $app.DisplayName.Trim().ToLower()
if ($normalized -notin $approvedNames) {
[PSCustomObject]@{
ComputerName = $app.ComputerName
DisplayName = $app.DisplayName
DisplayVersion = $app.DisplayVersion
Publisher = $app.Publisher
Status = "UNLICENSED"
}
}
}
Flagging Unlicensed Installations
After comparison, group violations by computer to produce a concise summary showing which machines have the most exposure. Group-Object with -NoElement gives a fast count without holding all objects in memory.
Exporting a Per-Machine Compliance Report
Export the full violations list to a single CSV and optionally write one file per machine for distribution to system owners. Use Export-Csv -NoTypeInformation to keep the output clean and importable by Excel or Power BI.
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$reportPath = "F:\audit\compliance-$timestamp.csv"
$violations | Export-Csv -Path $reportPath -NoTypeInformation
Write-Output "Report written to $reportPath — $($violations.Count) violation(s) found."
# Per-machine breakdown
$violations | Group-Object ComputerName | ForEach-Object {
Write-Output "$($_.Name): $($_.Count) violation(s)"
}
Common Errors
- Win32_Product triggers MSI repair: Using
Get-CimInstance Win32_Productlaunches a Windows Installer consistency check for every package. On machines with many MSIs this can take 10+ minutes and may reconfigure software. Always useGet-ItemPropertyon the registry path instead. - x86 registry hive missed: Querying only
HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstallon a 64-bit system skips 32-bit applications registered underWow6432Node. Always include both paths as shown in the examples above.
Related Cmdlets / See Also
Wrapping Up
Registry-based software enumeration is faster, safer, and more complete than Win32_Product queries. Pair it with parallel remote execution and an approved-software allowlist to build a compliance audit that scales to your entire fleet and exports actionable reports in minutes rather than hours.


