PowerShell Software License Compliance Audit Script

PowerShell Software License Compliance Audit Script

PowerShell Tips Editor 4 min read
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_Product launches a Windows Installer consistency check for every package. On machines with many MSIs this can take 10+ minutes and may reconfigure software. Always use Get-ItemProperty on the registry path instead.
  • x86 registry hive missed: Querying only HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall on a 64-bit system skips 32-bit applications registered under Wow6432Node. 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.

Send-Item -To