Error! Failed to perform the AJAX query
Error! You are not authorized to perform this action!
Error! A potentially dangerous request was detected and blocked. Please try again.

How to Inventory Microsoft Teams, Zoom and other User Based Applications

Created by Scott Fairchild on May 16, 2021
Tags:

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.


Error! Unauthorized! Unable to delete post.