How to Inventory Microsoft Teams and other User Based Applications

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.

bp17ci1

2. Right click and select Create Configuration Item

bp17ci2

The following Wizard will appear

bp17ci3

3. Enter a name for the CI and click Next

bp17ci4

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.

bp17ci5

5. On the Settings screen, click New

bp17ci6

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

bp17ci7

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"
bp17ci8

11. Click OK

bp17ci9

12. Click on the Compliance Rules tab

bp17ci10

13. Click New

bp17ci11

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

bp17ci12

19. Click OK

bp17ci13

20. Click Next

bp17ci14

21. Click Next

bp17ci15

22. Click Next

bp17ci16

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.

bp17ci17

25. Right click and select Create Configuration Baseline

bp17ci18

The following Wizard will appear

bp17ci19

26. Enter a name for the baseline
27. Click Add
28. Select Configuration Items

bp17ci20

29. Select the Configuration Item we just created
30. Click Add

bp17ci21

31. Click OK

bp17ci22

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

bp17ci23

The following screen appears

bp17ci24

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

bp17ci25

38. Right click on Default Client Settings and select Properties

bp17ci26

The following screen appears

bp17ci27

39. Click on Hardware Inventory

bp17hwinv1

40. Click on Set Classes

bp17hwinv2

41. Click Add

bp17hwinv3

42. Click Connect

bp17hwinv4

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

bp17hwinv5

46. Select the WMI Class defined in the $wmiCustomClass variable
47. Click OK

bp17hwinv6

48. Verify the inventory class is selected
49. Click OK

bp17ci27

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)

bp17after

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

bp17hwinv7

Upload the SSRS report and run it (change the Data Source to {5C6358F2-4BB6-4a1b-A16E-8D96795D8602})

bp17ssrsReport

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.


Share this post:

Comments:

UserPic

Andris Zēbergs

10/24/2022 1:32:49 PM (UTC)

Hi! Thank you very much for very usefull article! Could you please provide same report, but with option to filter only one application? I am not so advanced in customized report creaton. Thank you in advance!
UserPic

Scott Fairchild

10/24/2022 11:25:51 PM (UTC)

You can export the results to Excel and filter anyway you want. I'll work on a new report over the weekend for you.
UserPic

Scott Fairchild

10/29/2022 9:30:36 PM (UTC)

I added a new report called "Software Inventory - User Based - Choose Apps" to the download. After loading it in SSRS, select the collection first, then open the dropdown and select one or more applications to search for. If the application you want to search for is not in the list, then it isn't installed on any of the machines in the collection you selected.
UserPic

Andris Zēbergs

10/31/2022 7:29:39 AM (UTC)

Thank you very much!
UserPic

Lynnette No name

11/5/2022 10:03:20 PM (UTC)

Could you briefly explain the risks of loading the user dat files? Since we have many kiosk/shared devices, it would be impossible to gather data from logged on users only unless I ran HW Inventory 10+ a day. I'm leaning toward setting it false but am not familiar with the ramifications of loading/offloading the dat files if someone else is currently logged in.
UserPic

Scott Fairchild

11/5/2022 11:17:15 PM (UTC)

I've never run into an issue loading/unloading offline ntuser.dat files. If you've worked in IT long enough, you've probably run across a machine where the ntuser.dat file was corrupt, and the end user couldn't login. Because of this, some administrators are leery of touching the ntuser.dat file, which is why I added the option to skip offline profiles. It's really up to you whether you want to load them or not.