PowerShell Pipeline
Gathering Installed Software Using PowerShell
There's two ways to accomplish this task: the wrong way and the right way. Here's how to do both.
If there is one thing an administrator finds themselves doing, it is probably determining what software is installed on their system. It could be simply for just knowing what they have installed, or determining if some software installed may have vulnerabilities which are fixed via a security update or performing an audit for software (which may not have been approved to be installed). Either way, having a means to locate this software can be difficult if you do not have tools like SCCM or another third-party tool available to perform this type of audit.
PowerShell can help us in gathering the software on a local or remote system by giving us a couple of different options to perform the software gathering. One is through WMI and another is by looking in the registry.
The WMI Approach
I'm going to cover the WMI first only because you should never use it as a means to collect data on installed software. I'm talking about the Win32_Product class in WMI. This class is misused in a number of scripts because while it does provide you the information about the installed software, it comes with a cost associated with it.
To show this, I will perform a WMI lookup for software and then show you what happens as we are receiving data from WMI on installed software from this class.
Get-WmiObject -Class Win32_Product
The process is slow and painful as it will appear to hang for various periods of time before returning more data.
We do get useful information, but the speed of which it performs as well as another that occurs in the background make this the least useful approach. I mentioned that something else happens when you run a query against this class and that action is that the MSI installer will perform a consistency check and possible repair of each installed item under the Win32_Product class before it presents the data back to you. We can see this by using Get-WinEvent and looking at the Application log to see what is happening.
Get-WinEvent -FilterHashtable @{Logname='Application';Id=1035} -MaxEvents 10 |
Select-Object -ExpandProperty Message
This was just 10 of the over 400 events that flooded my computer when I started to query for installed software. Not a good thing performance wise and happening across your entire domain if you decided to gather data on all of your systems. Do yourself a favor and avoid dealing with Win32_Product at all costs!
The Registry Approach
The alternative to this is by digging into the registry to pull information about installed software. Scoping out the registry, we can find two paths that holds all of the data we need for software. Those paths are:
- HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall
- HKLM:\SOFTWARE\Wow6432node\Microsoft\Windows\CurrentVersion\Uninstall
A quick look at one of these paths using Regedit shows us that we are definitely on the right path.
Right under Uninstaller are a lot of GUIDs, but within each GUID we can see more information about the software that we can use in our data gathering.
I am going to show all of the code up front and then step through a couple of items that I feel are important to know that helps us in gathering the information.
Function Get-Software {
[OutputType('System.Software.Inventory')]
[Cmdletbinding()]
Param(
[Parameter(ValueFromPipeline=$True,ValueFromPipelineByPropertyName=$True)]
[String[]]$Computername=$env:COMPUTERNAME
)
Begin {
}
Process {
ForEach ($Computer in $Computername){
If (Test-Connection -ComputerName $Computer -Count 1 -Quiet) {
$Paths = @("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall","SOFTWARE\\Wow6432node\\Microsoft\\Windows\\CurrentVersion\\Uninstall")
ForEach($Path in $Paths) {
Write-Verbose "Checking Path: $Path"
# Create an instance of the Registry Object and open the HKLM base key
Try {
$reg=[microsoft.win32.registrykey]::OpenRemoteBaseKey('LocalMachine',$Computer,'Registry64')
} Catch {
Write-Error $_
Continue
}
# Drill down into the Uninstall key using the OpenSubKey Method
Try {
$regkey=$reg.OpenSubKey($Path)
# Retrieve an array of string that contain all the subkey names
$subkeys=$regkey.GetSubKeyNames()
# Open each Subkey and use GetValue Method to return the required values for each
ForEach ($key in $subkeys){
Write-Verbose "Key: $Key"
$thisKey=$Path+"\\"+$key
Try {
$thisSubKey=$reg.OpenSubKey($thisKey)
# Prevent Objects with empty DisplayName
$DisplayName = $thisSubKey.getValue("DisplayName")
If ($DisplayName -AND $DisplayName -notmatch '^Update for|rollup|^Security Update|^Service Pack|^HotFix') {
$Date = $thisSubKey.GetValue('InstallDate')
If ($Date) {
Try {
$Date = [datetime]::ParseExact($Date, 'yyyyMMdd', $Null)
} Catch{
Write-Warning "$($Computer): $_ <$($Date)>"
$Date = $Null
}
}
# Create New Object with empty Properties
$Publisher = Try {
$thisSubKey.GetValue('Publisher').Trim()
}
Catch {
$thisSubKey.GetValue('Publisher')
}
$Version = Try {
#Some weirdness with trailing [char]0 on some strings
$thisSubKey.GetValue('DisplayVersion').TrimEnd(([char[]](32,0)))
}
Catch {
$thisSubKey.GetValue('DisplayVersion')
}
$UninstallString = Try {
$thisSubKey.GetValue('UninstallString').Trim()
}
Catch {
$thisSubKey.GetValue('UninstallString')
}
$InstallLocation = Try {
$thisSubKey.GetValue('InstallLocation').Trim()
}
Catch {
$thisSubKey.GetValue('InstallLocation')
}
$InstallSource = Try {
$thisSubKey.GetValue('InstallSource').Trim()
}
Catch {
$thisSubKey.GetValue('InstallSource')
}
$HelpLink = Try {
$thisSubKey.GetValue('HelpLink').Trim()
}
Catch {
$thisSubKey.GetValue('HelpLink')
}
$Object = [pscustomobject]@{
Computername = $Computer
DisplayName = $DisplayName
Version = $Version
InstallDate = $Date
Publisher = $Publisher
UninstallString = $UninstallString
InstallLocation = $InstallLocation
InstallSource = $InstallSource
HelpLink = $thisSubKey.GetValue('HelpLink')
EstimatedSizeMB = [decimal]([math]::Round(($thisSubKey.GetValue('EstimatedSize')*1024)/1MB,2))
}
$Object.pstypenames.insert(0,'System.Software.Inventory')
Write-Output $Object
}
} Catch {
Write-Warning "$Key : $_"
}
}
} Catch {}
$reg.Close()
}
} Else {
Write-Error "$($Computer): unable to reach remote system!"
}
}
}
}
One thing to highlight is my use of $reg=[microsoft.win32.registrykey]::OpenRemoteBaseKey('LocalMachine',$Computer,'Registry64') which allows me to not only connect to my local computer, but also to connect to a remote system to work through the registry. Currently, the only other way to view the remote registry is by making use of PowerShell remoting which may or may not be available across all of your systems in your environment.
Once I have my registry connection, I simply begin working through each GUID under Uninstaller and begin gathering what I feel is useful information. I do this by making use of OpenSubkey() to get in each GUID and then working through all of the points of data by calling GetValue() and supplying the proper name of each property item that I want.
At the very end I take all of my data collected in each variable and create a new object using [pscustomobject] and output the object to the console so that I can sort, group or send that data to something like a CSV for later viewing.
Get-Software Function Demo
With the function completed, I can now use it by either calling it by name which will allow it to query the local machine that is running the code, or by using the Computername parameter.
Get-Software
This is just a handful of the software that I have installed but as you can see, there is quite a bit of useful information about each piece of software that is installed on my client.
Performance Testing
Just to highlight the performance impact between using the Get-Software function that I wrote using the registry as my means to collect software and using the Win32_Product class which will cause more annoyances than its worth, I put together a simple performance check using Measure-Command:
#Using Win32_Product
$Win32Product = Measure-Command {Get-WmiObject -Class Win32_product}
#Using Get-Software (Registry)
$GetSoftware = Measure-Command {Get-Software}
[pscustomobject]@{
Win32Product = $Win32Product.TotalMilliseconds
GetSoftware = $GetSoftware.TotalMilliseconds
}
The results speak for themselves: 24 seconds for WMI and less than a second for the registry. So remember that if you are going to look for software on your systems, make sure that you either use my Get-Software function or make your own that uses the registry to perform the lookup.
About the Author
Boe Prox is a Microsoft MVP in Windows PowerShell and a Senior Windows System Administrator. He has worked in the IT field since 2003, and he supports a variety of different platforms. He is a contributing author in PowerShell Deep Dives with chapters about WSUS and TCP communication. He is a moderator on the Hey, Scripting Guy! forum, and he has been a judge for the Scripting Games. He has presented talks on the topics of WSUS and PowerShell as well as runspaces to PowerShell user groups. He is an Honorary Scripting Guy, and he has submitted a number of posts as a to Microsoft's Hey, Scripting Guy! He also has a number of open source projects available on Codeplex and GitHub. His personal blog is at http://learn-powershell.net.