This PowerShell module I’ve developed to collect the software installed on all Windows servers within a forest. Requirements: PowerShell v2+, Remote Server Admin Tools (script uses AD cmdlets), & remote registry enable/permissions. Feel free to share this script but please provide credit if you do. Download the script here
DESCRIPTION:
It works by first getting a list of all Windows Servers in the AD forest then collecting registry entries from each of those servers and putting that into a table of data. It’s looking for the keys created by the Windows Installer/uninstaller (therefore this doesn’t list features/roles/standalone apps). It also can provide an error report which will tell you the severs that were successfully queried, failed to ping, or failed remote registry connection.
There are 2 functions in this module; Get-InstalledSoftware-Threaded & Get-InstalledSoftware.
By default these look for all Windows severs in the forest that the script is run in; you can limit the scope to a specific domain or to the name of severs matching your criteria. The threaded function allows parallel collection up to the thread limit (defaults to 1). So if you specify 50 threads and there 2000+ servers in the forest then 50 servers will be queued at the same time and as they finish new threads (powershell.exe instances) are generated until all severs are collected. When I tested it against 2000+ servers using 50 threads it ran in about 1.5Hrs with a server that had 2 cores and 8GB RAM. If you have a lot of server’s you want to get, use the threaded function. If you only have a few, use the regular function.
INSTRUCTIONS:
To run this in PowerShell save the file to your drive as a .psm1 file, then do the following in PowerShell:
Import-Module ‘C:\<Folder Location>\Get_InstalledSoftware.psm1’
Get-InstalledSoftware-Threaded -AppFile C:\reports\Report_Software.csv -LogFile C:\reports\Report_software_LOG.csv -Threads 50
– OR –
Get-InstalledSoftware -AppFile C:\reports\Report_Software.csv –LogFile C:\reports\Report_software_LOG.csv
PARAMETERS:
Both functions have a -Scope, -ComputerName, & -Filter parameter additionally.
-Scope allows specifying a domain whereas the default is every domain in the forest (e.g. Get-InstalledSoftware -Scope contoso.com)
-ComputerName allows for specifying the computer(s) to collect from. It’s an expression so you can use wildcards (e.g. Get-InstalledSoftware -ComputerName fitchdc*)
-Filter allows filtering out of applications from the results. By default Filter= “Security Update|Update for|Hotfix|Service Pack|Language Pack|Redistributable|.NET Framework”. To remove that as default use -Filter *
ADVANCED:
If you’re familiar with PowerShell you can also use this function to return the results into a variable, for example
$Results = Get-InstalledSoftware-Threaded -Threads 50 #$Results consists of 2 arrays. $Software = $Results[1] #Array with all the installed software listed in the registry $ErrorLog = $Results[0] #Array of results of the collection with successes and failures
SCRIPT:
# Author: Christopher Fitch # Date: 08/04/2014 # Purpose: Collects software installed on each server by looking at the uninstaller registry keys # Dependency: The modules listed below; remote admin tools, remote registry access function Get-InstalledSoftware { <# .EXAMPLE Get-InstalledSoftware -AppFile c:\temp\Report_Software.csv -LogFile c:\temp\Report_Software_LOG.csv -Scope contoso.com .EXAMPLE $Results = Get-InstalledSoftware -Scope contoso.com .PARAMETER AppFile Location to export CSV of Software found. Leave empty to not export CSV. .PARAMETER LogFile Location to export CSV of success and errors. Leave empty to not export CSV. .PARAMETER Filter Set to "*" to not filter results. Default="Security Update|Update for|Hotfix|Service Pack|Language Pack|Redistributable|.NET Framework" .PARAMETER Scope Default collection is every Windows Server in all forest domains. Set scope parameter to limit to specific domain. #> param( [string]$AppFile=$null, [string]$LogFile=$null, [string]$ComputerName="*", [string]$Filter="Security Update|Update for|Hotfix|Service Pack|Language Pack|Redistributable|.NET Framework", [System.Array]$Scope=(Get-ADForest | select -ExpandProperty Domains) ) $uninstallkey="SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall" $Servers = @() foreach ($Domain in $Scope) { $ClosestDC = Get-ADDomainController -DomainName $Domain -Discover -NextClosestSite | Select-Object -ExpandProperty HostName $ADDomain = Get-ADDomain -Identity $Domain try{ $Servers += Get-ADComputer -SearchBase $ADDomain.DistinguishedName -Server $ClosestDC -ResultPageSize 500 -Filter {OperatingSystem -like "Windows*Server*"} -Properties DNSHostName -SearchScope Subtree | Select -ExpandProperty DNSHostName } catch{ Write-Host "Error with Get-ADComputer for $Domain using $ClosestDC" try{ $AltDC = Get-ADDomain $Domain | Select-Object -ExpandProperty ReplicaDirectoryServers | Where-Object {$_ -ne $ClosestDC} | Select-Object -First 1 $Servers += Get-ADComputer -SearchBase $ADDomain.DistinguishedName -Server $AltDC -ResultPageSize 500 -Filter {OperatingSystem -like "Windows*Server*"} -Properties DNSHostName -SearchScope Subtree | Select -ExpandProperty DNSHostName } catch{ Write-Host "Error with Get-ADComputer for $Domain using $AltDC" } } } $Servers = $Servers | Where {$_ -like "$ComputerName"} $Count = $Servers.Count Write-Host "Starting query of $Count servers" $Measure = Measure-Command { $Software = @() $ErrorLog = @() foreach ($server in $Servers) { if (Test-Connection $server -Count 1 -ErrorAction SilentlyContinue){ try{ $apps = @() $reg=[microsoft.win32.registrykey]::OpenRemoteBaseKey('LocalMachine',$server) $regkey=$reg.OpenSubKey($uninstallkey) $subkeys=$regkey.GetSubKeyNames() foreach ($key in $subkeys){ $thisKey=$uninstallkey+"\\"+$key $thisSubKey=$reg.OpenSubKey($thisKey) $InstallDate = if($thisSubKey.GetValue("InstallDate") -ne $null){try{(get-date -Format d ([datetime]::ParseExact($($thisSubKey.GetValue("InstallDate")),"yyyyMMdd",[System.Globalization.CultureInfo]::CreateSpecificCulture("es-ES"))))}catch{$null}}else{$null} $obj = New-Object PSObject $obj | Add-Member -MemberType NoteProperty -Name "Uninstall Key" -Value ($Server+"\"+$key) $obj | Add-Member -MemberType NoteProperty -Name "Computer Name" -Value $server $obj | Add-Member -MemberType NoteProperty -Name "Display Name" -Value $($thisSubKey.GetValue("DisplayName")) $obj | Add-Member -MemberType NoteProperty -Name "Publisher" -Value $($thisSubKey.GetValue("Publisher")) $obj | Add-Member -MemberType NoteProperty -Name "Display Version" -Value $($thisSubKey.GetValue("DisplayVersion")) $obj | Add-Member -MemberType NoteProperty -Name "Install Source" -Value $($thisSubKey.GetValue("InstallSource")) $obj | Add-Member -MemberType NoteProperty -Name "Install Location" -Value $($thisSubKey.GetValue("InstallLocation")) $obj | Add-Member -MemberType NoteProperty -Name "Install Date" -Value $InstallDate $obj | Add-Member -MemberType NoteProperty -Name "Help Link" -Value $($thisSubKey.GetValue("HelpLink")) $obj | Add-Member -MemberType NoteProperty -Name "Fetch Timestamp" -Value (Get-Date) $apps += $obj } Write-Output "$($server) successful" $ResultOutput = @{"$($server)" = "successful"} $ErrorLog += $ResultOutput.GetEnumerator() $reg.Close() } catch{ Write-Output "$($server) failed Remote Registry" $ResultOutput = @{"$($server)" = "Failed Remote Registry"} $ErrorLog += $ResultOutput.GetEnumerator() } $Software += $apps | Where-Object {$_.'Display Name' -ne $null -and $_.'Display Name' -notmatch "(\$Filter)"} } else{ Write-Output "$($server) failed Test-Connection" $ResultOutput = @{"$($server)" = "Failed Test-Connection"} $ErrorLog += $ResultOutput.GetEnumerator() } } } Write-Host "Duration of Get-InstalledSoftware: $Measure" if($AppFile){$Software | Export-Csv $AppFile -NoTypeInformation} if($LogFile){$ErrorLog | Export-Csv $LogFile -NoTypeInformation} $Success = ($ErrorLog | Where {$_.Value -eq "successful"}).count $FailReg = ($ErrorLog | Where {$_.Value -eq "Failed Remote Registry"}).count $FailPing = ($ErrorLog | Where {$_.Value -eq "Failed Test-Connection"}).count Write-Host "Successful: $Success" Write-Host "Failed Remote Registry: $FailReg" Write-Host "Failed Test-Connection: $FailPing" ,$ErrorLog ,$Software } function Get-InstalledSoftware-Threaded { <# .EXAMPLE Get-InstalledSoftware-Threaded -AppFile c:\temp\Report_Software.csv -LogFile c:\temp\Report_Software_LOG.csv -Scope contoso.com .EXAMPLE $Results = Get-InstalledSoftware-Threaded -Scope contoso.com .EXAMPLE $Results = Get-InstalledSoftware-Threaded -Scope contoso.com -Threads 10 -ComputerName fitchdc0* .PARAMETER AppFile Location to export CSV of Software found. Leave empty to not export CSV. .PARAMETER LogFile Location to export CSV of success and errors. Leave empty to not export CSV. .PARAMETER Filter Set to "*" to not filter results. Default="Security Update|Update for|Hotfix|Service Pack|Language Pack|Redistributable|.NET Framework" .PARAMETER Scope Set scope parameter to limit to specific domain. Default collection is every Windows Server in all forest domains. #> param( [int]$Threads=1, [string]$AppFile=$null, [string]$LogFile=$null, [string]$ComputerName="*", [string]$Filter="Security Update|Update for|Hotfix|Service Pack|Language Pack|Redistributable|.NET Framework", [System.Array]$Scope=(Get-ADForest | select -ExpandProperty Domains) ) $uninstallkey="SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall" $Software = @() $ErrorLog = @() $Servers = @() foreach ($Domain in $Scope) { $ClosestDC = Get-ADDomainController -DomainName $Domain -Discover -NextClosestSite | Select-Object -ExpandProperty HostName $ADDomain = Get-ADDomain -Identity $Domain try{ $Servers += Get-ADComputer -SearchBase $ADDomain.DistinguishedName -Server $ClosestDC -ResultPageSize 500 -Filter {OperatingSystem -like "Windows*Server*"} -Properties DNSHostName -SearchScope Subtree | Select -ExpandProperty DNSHostName } catch{ Write-Host "Error with Get-ADComputer for $Domain using $ClosestDC" try{ $AltDC = Get-ADDomain $Domain | Select-Object -ExpandProperty ReplicaDirectoryServers | Where-Object {$_ -ne $ClosestDC} | Select-Object -First 1 $Servers += Get-ADComputer -SearchBase $ADDomain.DistinguishedName -Server $AltDC -ResultPageSize 500 -Filter {OperatingSystem -like "Windows*Server*"} -Properties DNSHostName -SearchScope Subtree | Select -ExpandProperty DNSHostName } catch{ Write-Host "Error with Get-ADComputer for $Domain using $AltDC" } } } $Queue = [System.Collections.Queue]::Synchronized((New-Object System.Collections.Queue)) foreach ($Server in $Servers -like "$ComputerName"){$Queue.Enqueue($Server)} $Count = $Queue.count Write-Host "Starting query of $Count servers" $Measure = Measure-Command { while ($Queue.Count -ne 0) { $CurrentJobs = @(Get-Job | Where-Object {$_.State -eq 'Running' -or $_.State -eq 'Completed'}) #Start jobs with max thread queue if ($CurrentJobs.Count -lt $threads) { $Server = $Queue.Dequeue() $Name = $Server.Split('.') | Select-Object -First 1 #Start threaded job Start-Job -Name $Name -ScriptBlock { Param($Server,$uninstallkey,$Filter) if (Test-Connection $Server -Count 1 -ErrorAction SilentlyContinue){ try{ $apps = @() $reg=[microsoft.win32.registrykey]::OpenRemoteBaseKey('LocalMachine',$server) $regkey=$reg.OpenSubKey($uninstallkey) $subkeys=$regkey.GetSubKeyNames() foreach ($key in $subkeys){ $thisKey=$uninstallkey+"\\"+$key $thisSubKey=$reg.OpenSubKey($thisKey) $InstallDate = if($thisSubKey.GetValue("InstallDate") -ne $null){try{(get-date -Format d ([datetime]::ParseExact($($thisSubKey.GetValue("InstallDate")),"yyyyMMdd",[System.Globalization.CultureInfo]::CreateSpecificCulture("es-ES"))))}catch{$null}}else{$null} $obj = New-Object PSObject $obj | Add-Member -MemberType NoteProperty -Name "Uninstall Key" -Value ($Server+"\"+$key) $obj | Add-Member -MemberType NoteProperty -Name "Computer Name" -Value $server $obj | Add-Member -MemberType NoteProperty -Name "Display Name" -Value $($thisSubKey.GetValue("DisplayName")) $obj | Add-Member -MemberType NoteProperty -Name "Publisher" -Value $($thisSubKey.GetValue("Publisher")) $obj | Add-Member -MemberType NoteProperty -Name "Display Version" -Value $($thisSubKey.GetValue("DisplayVersion")) $obj | Add-Member -MemberType NoteProperty -Name "Install Source" -Value $($thisSubKey.GetValue("InstallSource")) $obj | Add-Member -MemberType NoteProperty -Name "Install Location" -Value $($thisSubKey.GetValue("InstallLocation")) $obj | Add-Member -MemberType NoteProperty -Name "Install Date" -Value $InstallDate $obj | Add-Member -MemberType NoteProperty -Name "Help Link" -Value $($thisSubKey.GetValue("HelpLink")) $obj | Add-Member -MemberType NoteProperty -Name "Fetch Timestamp" -Value (Get-Date) $Apps += $obj } $ResultOutput = @{"$($server)" = "successful"} $Errors = $ResultOutput.GetEnumerator() $reg.Close() } catch{ $ResultOutput = @{"$($server)" = "Failed Remote Registry"} $Errors = $ResultOutput.GetEnumerator() } } else{ $ResultOutput = @{"$($server)" = "Failed Test-Connection"} $Errors += $ResultOutput.GetEnumerator() } $Errors $Apps | Where-Object {$_.'Display Name' -ne $null -and $_.'Display Name' -notmatch "(\$Filter)"} } -ArgumentList $Server,$uninstallkey,$Filter | Out-Null Write-Host "Start: $($Server)" } else { $Running = @(Get-Job | Where-Object {$_.State -eq 'Running'}) #If the max threads are running than wait on any to complete if ($Running.Count -ge $threads) { Write-Host "Waiting On: $($Running.Name)" $Running | Wait-Job -Any | Out-Null } $Completed = @(Get-Job | Where-Object {$_.State -eq 'Completed'}) #When one of the running jobs completes retrieve results and continue with queue if ($Completed -ne $null) { foreach ($Job in $Completed) { $JobResults = $null $JobResults = $Job | Receive-Job Write-Host "Receive Job: $($Job.Name)" $ErrorLog += $JobResults | select -Index 0 $Software += $JobResults | select -Skip 1 | Select 'Uninstall Key','Computer Name','Display Name',Publisher,'Display Version','Install Source','Install Location','Install Date','Help Link','Fetch Timestamp' $Job | Remove-Job } } } } #After last jobs in queue starts this will wait for and retrieve any remaining $Running = @(Get-Job | Where-Object {$_.State -eq 'Running'}) Write-Host "Waiting On: $($Running.Name)" $Running | Wait-Job | Out-Null $Completed = @(Get-Job | Where-Object {$_.State -eq 'Completed'}) foreach ($Job in $Completed) { $JobResults = $null $JobResults = $Job | Receive-Job Write-Host "Receive Job: $($Job.Name)" $ErrorLog += $JobResults | select -Index 0 $Software += $JobResults | select -Skip 1 | Select 'Uninstall Key','Computer Name','Display Name',Publisher,'Display Version','Install Source','Install Location','Install Date','Help Link','Fetch Timestamp' $Job | Remove-Job } } if($AppFile){$Software | Export-Csv $AppFile -NoTypeInformation} if($LogFile){$ErrorLog | Export-Csv $LogFile -NoTypeInformation} $Success = ($ErrorLog | Where {$_.Value -eq "successful"}).count $FailReg = ($ErrorLog | Where {$_.Value -eq "Failed Remote Registry"}).count $FailPing = ($ErrorLog | Where {$_.Value -eq "Failed Test-Connection"}).count Write-Host "Duration of Get-InstalledSoftware: $Measure" Write-Host "Successful: $Success" Write-Host "Failed Remote Registry: $FailedReg" Write-Host "Failed Test-Connection $FailPing" Remove-Job * -Force ,$ErrorLog ,$Software }