How to Inventory Microsoft Teams and other User Based Applications
Date Created: May 16, 2021 (UTC)Last Updated: May 16, 2021 (UTC)
Recently I was in a meeting with a client and we were discussing users that had installed Zoom on their machine. Zoom is not an approved application for the client. When I checked the SCCM/MECM inventory, it did not show Zoom installed on any client, even though users were running it. This is because Zoom is a user based application, meaning it is installed in the user's profile in the AppData folder, and not in Program Files. Microsoft Teams is also a user based application.
Microsoft Endpoint Configuration Manager (SCCM/MECM) does not inventory user applications because the registry entries are stored in HKEY_CURRENT_USER, not HKEY_LOCAL_MACHINE
This presented a challenge. How do you inventory applications that are stored in a user's profile?
My solution was to create a PowerShell script that read HKEY_USERS to gather the list of installed applications. This list was then added to a custom WMI class. Once it was in WMI, I extended the MECM Hardware Inventory to read in this data. I also created a custom SSRS Report so the client could actually see the data in an easily readable format. The report will be included in the download at the end of this post.
Before getting into the steps to implement the solution, I wanted to point out the five (5) variables in the PowerShell script you can change.
$wmiCustomNamespace = "ITLocal" # Will be created under the ROOT namespace
$wmiCustomClass = "User_Based_Applications" # Will be created in the $wmiCustomNamespace. Will also be used to name the view in Configuration Manager
$DoNotLoadOfflineProfiles = $false # Prevents loading the ntuser.dat file for users that are not logged on
$LogFilePath = "C:\Windows\CCM\Logs" # Location where the log file will be stored
$maxLogFileSize = "5MB" # Sets the maximum size for the log file before it rolls over
The most important variable is $wmiCustomClass. Whatever this variable is set to, is the name of the view that will be created in the Configuration Manager database. Make sure the name you choose does not already exist in the Configuration Manager database. The SSRS report I will provide queries against [dbo].[v_GS_USER_BASED_APPLICATIONS]. If you change the name of $wmiCustomClass, you will need to modify the report so it queries the correct view.
The only other variable you may want to change is $DoNotLoadOfflineProfiles. By default, when the script runs, it will go into each user's profile that is not logged on, load the ntuser.dat file, query the user installed applications, and then unload ntuser.dat. Some people may be weary of mounting the ntuser.dat file. If $DoNotLoadOfflineProfiles = $true, only logged on user profiles will be read. Logged on users already have the ntuser.dat file loaded in memory.
The script will create a log file called Get-UserApplications.log in $LogFilePath so you can see the results.
I chose to use a Configuration Baseline to run the script. This was done so it can be run at whatever schedule matches the Hardware Inventory schedule. You can also target it to collections, so not everyone will receive the policy. This is great for testing.
Now that that is out of the way, here are the steps to implement.
The first thing you need to do is create a Configuration Item
1. Go into the MECM console and under Assets and Compliance expand the Compliance Settings folder and click on Configuration Items.
2. Right click and select Create Configuration Item
The following Wizard will appear
3. Enter a name for the CI and click Next
4. Select the Operating Systems you want to target and click Next
Note: The script requires the latest version of PowerShell to run. So if you target Windows 7 machines, make sure PowerShell is updated.
5. On the Settings screen, click New
6. On the Create Setting screen enter a name
7. Change the Setting Type to Script
8. Change the Data Type to String
9. Click Add Script
10. Copy and Paste the following PowerShell code into the window (the script will be included in the download at the end of this post)
# Script Name: Get-UserApplications.ps1
# Created by: Scott Fairchild
# https://www.scottjfairchild.com
# Based off the "Modifying the Registry for All Users" script from PDQ found at https://www.pdq.com/blog/modifying-the-registry-users-powershell/
# NOTE: When the WMI class is added to Configuration Manager Hardware Inventory,
# Configuration Manager will create a view called v_GS_<Whatever You Put In The $wmiCustomClass Variable>
# You can then create custom reports against that view.
# Set script variables
$wmiCustomNamespace = "ITLocal" # Will be created under the ROOT namespace
$wmiCustomClass = "User_Based_Applications" # Will be created in the $wmiCustomNamespace. Will also be used to name the view in Configuration Manager
$DoNotLoadOfflineProfiles = $false # Prevents loading the ntuser.dat file for users that are not logged on
$LogFilePath = "C:\Windows\CCM\Logs" # Location where the log file will be stored
$maxLogFileSize = "5MB" # Sets the maximum size for the log file before it rolls over
# *******************************************************************************************
# DO NOT MODIFY ANYTHING BELOW THIS LINE
# *******************************************************************************************
# Function to write to a log file in Configuration Manager format
function Write-CMLogEntry {
param (
[parameter(Mandatory = $true, HelpMessage = "Text added to the log file.")]
[ValidateNotNullOrEmpty()]
[string]$Value,
[parameter(Mandatory = $true, HelpMessage = "Severity for the log entry. 1 for Informational, 2 for Warning and 3 for Error.")]
[ValidateNotNullOrEmpty()]
[ValidateSet("1", "2", "3")]
[string]$Severity
)
# Calculate log file names based on the name of the running script
#$scriptFullPath = $myInvocation.ScriptName -split "\\" # Ex: C:\Windows\Temp\Get-UserApplications.ps1
#$scriptFullName = $scriptFullPath[($scriptFullPath).Length - 1] # Get-UserApplications.ps1
#$CmdletName = $scriptFullName -split ".ps1" # Get-UserApplications
#$LogFileName = "$($CmdletName[0]).log" # Get-UserApplications.log
#$OldLogFileName = "$($CmdletName[0]).lo_" # Get-UserApplications.lo_
# Hard Code names because script Configuration Items create a file that uses a GUID as the name
$LogFileName = "Get-UserApplications.log"
$OldLogFileName = "Get-UserApplications.lo_"
$CmdletName = @('Get-UserApplications')
# Set log file location
$LogFile = Join-Path $LogFilePath $LogFileName # C:\Windows\CCM\Logs\Get-UserApplications.log
$OldLogFile = Join-Path $LogFilePath $OldLogFileName # C:\Windows\CCM\Logs\Get-UserApplications.lo_
# Rotate log file if needed
if ( (Get-Item $LogFile -ea SilentlyContinue).Length -gt $maxLogFileSize ) {
# Delete old log file
if (Get-Item $OldLogFile -ea SilentlyContinue) {
Remove-Item $OldLogFile
}
# Rename current log to old log
Rename-Item -Path $LogFile -NewName $OldLogFileName
}
# Construct time stamp for log entry
$Time = -join @((Get-Date -Format "HH:mm:ss.fff"), (Get-CimInstance -Class Win32_TimeZone | Select-Object -ExpandProperty Bias))
# Construct date for log entry
$Date = (Get-Date -Format "MM-dd-yyyy")
# Construct final log entry
$LogText = "<![LOG[$($Value)]LOG]!><time=""$($Time)"" date=""$($Date)"" component=""$($CmdletName[0])"" context="""" type=""$($Severity)"" thread=""$($PID)"" file="""">"
# Add text to log file and output to screen
try {
Out-File -InputObject $LogText -Append -NoClobber -Encoding Default -FilePath $LogFile -ErrorAction Stop
}
catch [System.Exception] {
Write-Warning -Message "Unable to append log entry to $LogFileName file. Error message: $($_.Exception.Message)"
}
}
Write-CMLogEntry -Value "****************************** Script Started ******************************" -Severity 1
if ($DoNotLoadOfflineProfiles) {
Write-CMLogEntry -Value "DoNotLoadOfflineProfiles = True. Only logged in users will be checked" -Severity 1
}
else {
Write-CMLogEntry -Value "DoNotLoadOfflineProfiles = False. All user profiles will be checked" -Severity 1
}
# Check if custom WMI Namespace Exists. If not, create it.
$namespaceExists = Get-CimInstance -Namespace root -ClassName __Namespace -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq $wmiCustomNamespace }
if (-not $namespaceExists) {
Write-CMLogEntry -Value "$wmiCustomNamespace WMI Namespace does not exist. Creating..." -Severity 1
$ns = [wmiclass]'ROOT:__namespace'
$sc = $ns.CreateInstance()
$sc.Name = $wmiCustomNamespace
$sc.Put() | Out-Null
}
# Check if custom WMI Class Exists. If not, create it.
$classExists = Get-CimClass -Namespace root\$wmiCustomNamespace -ClassName $wmiCustomClass -ErrorAction SilentlyContinue
if (-not $classExists) {
Write-CMLogEntry -Value "$wmiCustomClass WMI Class does not exist in the ROOT\$wmiCustomNamespace namespace. Creating..." -Severity 1
$newClass = New-Object System.Management.ManagementClass ("ROOT\$($wmiCustomNamespace)", [String]::Empty, $null);
$newClass["__CLASS"] = $wmiCustomClass;
$newClass.Qualifiers.Add("Static", $true)
$newClass.Properties.Add("UserName", [System.Management.CimType]::String, $false)
$newClass.Properties["UserName"].Qualifiers.Add("Key", $true)
$newClass.Properties.Add("ProdID", [System.Management.CimType]::String, $false)
$newClass.Properties["ProdID"].Qualifiers.Add("Key", $true)
$newClass.Properties.Add("DisplayName", [System.Management.CimType]::String, $false)
$newClass.Properties.Add("InstallDate", [System.Management.CimType]::String, $false)
$newClass.Properties.Add("Publisher", [System.Management.CimType]::String, $false)
$newClass.Properties.Add("DisplayVersion", [System.Management.CimType]::String, $false)
$newClass.Put() | Out-Null
}
if ($DoNotLoadOfflineProfiles -eq $false) {
# Remove current inventory records from WMI
# This is done so Hardware Inventory can pick up applications that have been removed
Write-CMLogEntry -Value "Clearing current inventory records" -Severity 1
Get-CimInstance -Namespace root\$wmiCustomNamespace -Query "Select * from $wmiCustomClass" | Remove-CimInstance
}
# Regex pattern for SIDs
$PatternSID = 'S-1-5-21-\d+-\d+\-\d+\-\d+$'
# Get all logged on user SIDs found in HKEY_USERS (ntuser.dat files that are loaded)
Write-CMLogEntry -Value "Identifying users who are logged on" -Severity 1
$LoadedHives = Get-ChildItem Registry::HKEY_USERS | Where-Object { $_.PSChildname -match $PatternSID } | Select-Object @{name = "SID"; expression = { $_.PSChildName } }
if ($LoadedHives) {
# Log all logged on users
foreach ($userSID in $LoadedHives) {
Write-CMLogEntry -Value "-> $userSID" -Severity 1
}
}
else {
Write-CMLogEntry -Value "-> None Found" -Severity 1
}
if ($DoNotLoadOfflineProfiles -eq $false) {
# Get SID and location of ntuser.dat for all users
Write-CMLogEntry -Value "All user profiles on machine" -Severity 1
$ProfileList = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\ProfileList\*' | Where-Object { $_.PSChildName -match $PatternSID } |
Select-Object @{name = "SID"; expression = { $_.PSChildName } },
@{name = "UserHive"; expression = { "$($_.ProfileImagePath)\ntuser.dat" } }
# Log All User Profiles
foreach ($userSID in $ProfileList) {
Write-CMLogEntry -Value "-> $userSID" -Severity 1
}
# Compare logged on users to all profiles and remove loggon on users from list
Write-CMLogEntry -Value "Profiles that have to be loaded from disk" -Severity 1
# If logged on users found, compare profile list to see which ones are logged off
if ($LoadedHives) {
$UnloadedHives = Compare-Object $ProfileList.SID $LoadedHives.SID | Select-Object @{name = "SID"; expression = { $_.InputObject } }
}
else { # No logged on users found so lets load all profiles
$UnloadedHives = $ProfileList | Select-Object -Property SID
}
# Log SID's that need to be loaded
if ($UnloadedHives) {
foreach ($userSID in $UnloadedHives) {
Write-CMLogEntry -Value "-> $userSID" -Severity 1
}
}
}
# Determine list of users we will iterate over
$profilesToQuery = $null
if ($DoNotLoadOfflineProfiles) {
if ($LoadedHives) {
$profilesToQuery = $LoadedHives
}
else {
Write-CMLogEntry -Value "No users are logged on. Exiting..." -Severity 1
Write-CMLogEntry -Value "****************************** Script Finished ******************************" -Severity 1
Return "True"
Exit
}
}
else {
$profilesToQuery = $ProfileList
}
# Loop through each profile
Foreach ($item in $profilesToQuery) {
Write-CMLogEntry -Value "-------------------------------------------------------------------------------------------------------------" -Severity 1
$userName = ''
# Get user name associated with profile from SID
$objSID = New-Object System.Security.Principal.SecurityIdentifier ($item.SID)
$userName = $objSID.Translate( [System.Security.Principal.NTAccount]).ToString()
if ($DoNotLoadOfflineProfiles) {
# Remove current inventory records from WMI
# This is done so Hardware Inventory can pick up applications that have been removed
Write-CMLogEntry -Value "Clearing out current inventory for $userName" -Severity 1
$escapedUserName = $userName.Replace('\', '\\')
$delItem = Get-CimInstance -Namespace root\$wmiCustomNamespace -Query "Select * from $wmiCustomClass where UserName = '$escapedUserName'"
if ($delItem) {
$delItem | Remove-CimInstance
}
}
# Load ntuser.dat if the user is not logged on
if ($DoNotLoadOfflineProfiles -eq $false) {
if ($item.SID -in $UnloadedHives.SID) {
Write-CMLogEntry -Value "Loading user hive for $userName from $($Item.UserHive)" -Severity 1
reg load HKU\$($Item.SID) $($Item.UserHive) | Out-Null
}
else {
Write-CMLogEntry -Value "$UserName is logged on. No need to load hive from disk" -Severity 1
}
}
Write-CMLogEntry -Value "Getting installed User applications for $userName" -Severity 1
# Define x64 apps location
$userApps = Get-ChildItem -Path Registry::HKEY_USERS\$($Item.SID)\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall -ErrorAction SilentlyContinue
if ($userApps) {
Write-CMLogEntry -Value "Found user installed applications" -Severity 1
# parse each app
$userApps | ForEach-Object {
# Clear current values
$ProdID = ''
$DisplayName = ''
$InstallDate = ''
$Publisher = ''
$DisplayVersion = ''
# Get Key name
$path = $_.PSPath
$arrTemp = $_.PSPath -split "\\"
$ProdID = $arrTemp[($arrTemp).Length - 1]
# Iterate key and get all properties and values
$_.Property | ForEach-Object {
$prop = $_
$value = Get-ItemProperty -literalpath $path -name $prop | Select-Object -expand $prop
switch ( $prop ) {
DisplayName { $DisplayName = $value }
InstallDate { $InstallDate = $value }
Publisher { $Publisher = $value }
DisplayVersion { $DisplayVersion = $value }
}
}
Write-CMLogEntry -Value "-> Adding $DisplayName" -Severity 1
# Create new instance in WMI
$newRec = New-CimInstance -Namespace root\$wmiCustomNamespace -ClassName $wmiCustomClass -Property @{UserName = "$userName"; ProdID = "$ProdID" }
# Add properties
$newRec.DisplayName = $DisplayName
$newRec.InstallDate = $InstallDate
$newRec.Publisher = $Publisher
$newRec.DisplayVersion = $DisplayVersion
# Save to WMI
$newRec | Set-CimInstance
}
}
else {
Write-CMLogEntry -Value "No user applications found" -Severity 1
}
if ($DoNotLoadOfflineProfiles -eq $false) {
# Unload ntuser.dat
# Let's do everything possible to make sure we no longer have a hook into the user profile,
# because if we do, an Access Denied error will be displayed when trying to unload.
IF ($item.SID -in $UnloadedHives.SID) {
# check if we loaded the hive
Write-CMLogEntry -Value "Unloading user hive for $userName" -Severity 1
# Close Handles
If ($userApps) {
$userApps.Handle.Close()
}
# Set variable to $null
$userApps = $null
# Garbage collection
[gc]::Collect()
# Sleep for 2 seconds
Start-Sleep -Seconds 2
#unload registry hive
reg unload HKU\$($Item.SID) | Out-Null
}
}
}
Write-CMLogEntry -Value "****************************** Script Finished ******************************" -Severity 1
Return "True"
11. Click OK
12. Click on the Compliance Rules tab
13. Click New
14. Enter a name for the Rule
15. Verify the Operator field is set to Equals
16. Enter True in the For the following values field
17. Check Report noncompliance if this setting instance is not found
18. Click OK
Note: The PowerShell script will always return True
19. Click OK
20. Click Next
21. Click Next
22. Click Next
23. Click Close
Now that the CI has been created, we need to create a Configuration Baseline so we can deploy it
24. Right click on Configuration Baselines.
25. Right click and select Create Configuration Baseline
The following Wizard will appear
26. Enter a name for the baseline
27. Click Add
28. Select Configuration Items
29. Select the Configuration Item we just created
30. Click Add
31. Click OK
32. Click OK
Now that the Configuration Baseline has been created, let's deploy it
33. Right click on the Configuration Baseline and select Deploy
The following screen appears
34. Click Select the collection you want to deploy to
35. Set the schedule you want the use
36. Click OK
Now that the Configuration Baseline is deployed, your machines will collect user installed applications based on the schedule you set.
Now we need to extend the Configuration Manager Hardware Inventory by adding the new WMI class the script created
WARNING! ADDING A NEW WMI CLASS TO HARDWARE INVENTORY ALTERS THE CONFIGURATION MANAGER DATABASE. MAKE SURE YOU HAVE A GOOD BACKUP BEFORE PROCEEDING!
You will need to access a machine that has already run the PowerShell script, either manually or through the Configuration Baseline, before proceeding
Note: The next set of steps need to be performed from the Primary Site Server or CAS
37. In the MECM console under Administration, click on Client Settings
38. Right click on Default Client Settings and select Properties
The following screen appears
39. Click on Hardware Inventory
40. Click on Set Classes
41. Click Add
42. Click Connect
43. In the Computer Name field enter the name of the computer that you already ran the PowerShell script on
44. Enter root\ followed by the WMI Namespace defined in the $wmiCustomNamespace variable
45. Click Connect
46. Select the WMI Class defined in the $wmiCustomClass variable
47. Click OK
48. Verify the inventory class is selected
49. Click OK
50. Click OK
If you open SQL Server Management Studio you will now see a new View called v_GS_USER_BASED_APPLICATIONS (or whatever name you set in $wmiCustomClass)
51. On a workstation that had the PowerShell script run on it, initiate a Machine Policy Retrieval & Evaluation Cycle. Then initiate a Hardware Inventory Cycle
52. On the client, open C:\Windows\CCM\Logs\InventoryAgent.log
53. Search the log for the WMI class the script created and verify it was successfully gathered by Hardware Inventory
Upload the SSRS report and run it (change the Data Source to {5C6358F2-4BB6-4a1b-A16E-8D96795D8602})
Note: The Report is RBAC enabled so users will only see collections they have access to
Enjoy!
Download: GetUserInstalledApplications.zip
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Andris Zēbergs
10/24/2022 1:32:49 PM (UTC)
Scott Fairchild
10/24/2022 11:25:51 PM (UTC)
Scott Fairchild
10/29/2022 9:30:36 PM (UTC)
Andris Zēbergs
10/31/2022 7:29:39 AM (UTC)
Lynnette No name
11/5/2022 10:03:20 PM (UTC)
Scott Fairchild
11/5/2022 11:17:15 PM (UTC)
Ahmed Hassanein
2/28/2023 4:35:13 AM (UTC)
Scott Fairchild
2/28/2023 5:09:43 AM (UTC)
Randy Davenport
6/7/2023 1:38:10 PM (UTC)
Scott Fairchild
6/14/2023 1:50:00 AM (UTC)
Jim English
7/12/2023 3:05:25 PM (UTC)
Scott Fairchild
7/13/2023 7:21:29 PM (UTC)