Retrieving Installed Software from all Servers in Forest Automatically

Standard

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
}

 

Leave a Reply

Please log in using one of these methods to post your comment:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.