Installation Guide

Windows 10 Software Inventory Configuration

Configuring the Inventory Collection

Prior to starting this configuration you should have configured the Enterprise App Registration , configured the Log Analytics workspace and enrolled your devices into Endpoint Analytics as mentioned in the previous steps.

Next you will configured a proactive remediation In Endpoint Analytics that runs a PowerShell script on a schedule. This PowerShell script collects the installed software information from the Windows 10 clients and stores it in Log Analytics where it will then be sent to PowerBI.

Script credits to: Jan Ketil Skanke and Sandy Zeng


Copy the following script, or download it from here https://bi.fatstacks.tech/media/Invoke-Inventory.zip ,edit it entering the Log Analytics Workspace ID and Log Analytics Primary Key that you recorded in the earlier steps of this documentation.

<# Original script credit to Jan Ketil Skanke and Sandy Zeng


https://msendpointmgr.com/2021/04/12/enhance-intune-inventory-data-with-proactive-remediations-and-log-analytics/

https://github.com/MSEndpointMgr/Intune/blob/master/Montoring/CustomInventory/Invoke-CustomInventory.ps1

.DESCRIPTION

This script will collect device app inventory and upload this to a Log Analytics Workspace. This allows you to easily search in device hardware and installed apps inventory.

The script is meant to be runned on a daily schedule either via Proactive Remediations (RECOMMENDED) in Intune or manually added as local schedule task on your Windows 10 Computer.


The orginal script has been edited by FatStacks to work seamlessly with PowerBI.


#>


#region initialize

# Enable TLS 1.2 support

[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

# Replace with your Log Analytics Workspace ID

$CustomerId = ""


# Replace with your Primary Key

$SharedKey = ""


#Control if you want to collect App or Device Inventory or both (True = Collect)

$CollectDeviceInventory = $true

$CollectAppInventory = $true


# You can use an optional field to specify the timestamp from the data. If the time field is not specified, Azure Monitor assumes the time is the message ingestion time

# DO NOT DELETE THIS VARIABLE. Recommened keep this blank.

$TimeStampField = ""


#endregion initialize


#region functions


# Function to get all Installed Application

function Get-InstalledApplications() {

param(

[string]$UserSid

)

New-PSDrive -PSProvider Registry -Name "HKU" -Root HKEY_USERS | Out-Null

$regpath = @("HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\*")

$regpath += "HKU:\$UserSid\Software\Microsoft\Windows\CurrentVersion\Uninstall\*"

if (-not ([IntPtr]::Size -eq 4)) {

$regpath += "HKLM:\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"

$regpath += "HKU:\$UserSid\Software\Wow6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*"

}

$propertyNames = 'DisplayName', 'DisplayVersion', 'Publisher', 'UninstallString'

$Apps = Get-ItemProperty $regpath -Name $propertyNames -ErrorAction SilentlyContinue | . { process { if ($_.DisplayName) { $_ } } } | Select-Object DisplayName, DisplayVersion, Publisher, UninstallString, PSPath | Sort-Object DisplayName

Remove-PSDrive -Name "HKU" | Out-Null

Return $Apps

}


# Function to create the authorization signature

Function New-Signature ($customerId, $sharedKey, $date, $contentLength, $method, $contentType, $resource) {

$xHeaders = "x-ms-date:" + $date

$stringToHash = $method + "`n" + $contentLength + "`n" + $contentType + "`n" + $xHeaders + "`n" + $resource


$bytesToHash = [Text.Encoding]::UTF8.GetBytes($stringToHash)

$keyBytes = [Convert]::FromBase64String($sharedKey)


$sha256 = New-Object System.Security.Cryptography.HMACSHA256

$sha256.Key = $keyBytes

$calculatedHash = $sha256.ComputeHash($bytesToHash)

$encodedHash = [Convert]::ToBase64String($calculatedHash)

$authorization = 'SharedKey {0}:{1}' -f $customerId, $encodedHash

return $authorization

}


# Function to create and post the request

Function Send-LogAnalyticsData($customerId, $sharedKey, $body, $logType) {

$method = "POST"

$contentType = "application/json"

$resource = "/api/logs"

$rfc1123date = [DateTime]::UtcNow.ToString("r")

$contentLength = $body.Length

$signature = New-Signature `

-customerId $customerId `

-sharedKey $sharedKey `

-date $rfc1123date `

-contentLength $contentLength `

-method $method `

-contentType $contentType `

-resource $resource

$uri = "https://" + $customerId + ".ods.opinsights.azure.com" + $resource + "?api-version=2016-04-01"

#validate that payload data does not exceed limits

if ($body.Length -gt (31.9 *1024*1024))

{

throw("Upload payload is too big and exceed the 32Mb limit for a single upload. Please reduce the payload size. Current payload size is: " + ($body.Length/1024/1024).ToString("#.#") + "Mb")

}


$payloadsize = ("Upload payload size is " + ($body.Length/1024).ToString("#.#") + "Kb ")


$headers = @{

"Authorization" = $signature;

"Log-Type" = $logType;

"x-ms-date" = $rfc1123date;

"time-generated-field" = $TimeStampField;

}


$response = Invoke-WebRequest -Uri $uri -Method $method -ContentType $contentType -Headers $headers -Body $body -UseBasicParsing

$statusmessage = "$($response.StatusCode) : $($payloadsize)"

return $statusmessage

}

function Start-PowerShellSysNative {

param (

[parameter(Mandatory = $false, HelpMessage = "Specify arguments that will be passed to the sysnative PowerShell process.")]

[ValidateNotNull()]

[string]$Arguments

)


# Get the sysnative path for powershell.exe

$SysNativePowerShell = Join-Path -Path ($PSHOME.ToLower().Replace("syswow64", "sysnative")) -ChildPath "powershell.exe"


# Construct new ProcessStartInfo object to run scriptblock in fresh process

$ProcessStartInfo = New-Object -TypeName System.Diagnostics.ProcessStartInfo

$ProcessStartInfo.FileName = $SysNativePowerShell

$ProcessStartInfo.Arguments = $Arguments

$ProcessStartInfo.RedirectStandardOutput = $true

$ProcessStartInfo.RedirectStandardError = $true

$ProcessStartInfo.UseShellExecute = $false

$ProcessStartInfo.WindowStyle = "Hidden"

$ProcessStartInfo.CreateNoWindow = $true


# Instatiate the new 64-bit process

$Process = [System.Diagnostics.Process]::Start($ProcessStartInfo)


# Read standard error output to determine if the 64-bit script process somehow failed

$ErrorOutput = $Process.StandardError.ReadToEnd()

if ($ErrorOutput) {

Write-Error -Message $ErrorOutput

}

}#endfunction

#endregion functions


#region script

#Get Common data for App and Device Inventory:

#Get Intune DeviceID and ManagedDeviceName

if (@(Get-ChildItem HKLM:SOFTWARE\Microsoft\Enrollments\ -Recurse | Where-Object { $_.PSChildName -eq 'MS DM Server' })) {

$MSDMServerInfo = Get-ChildItem HKLM:SOFTWARE\Microsoft\Enrollments\ -Recurse | Where-Object { $_.PSChildName -eq 'MS DM Server' }

$ManagedDeviceInfo = Get-ItemProperty -LiteralPath "Registry::$($MSDMServerInfo)"

}

$ManagedDeviceName = $ManagedDeviceInfo.EntDeviceName

$ManagedDeviceID = $ManagedDeviceInfo.EntDMID

#Get Computer Info

$ComputerInfo = Get-ComputerInfo

$ComputerName = $ComputerInfo.CsName

$ComputerManufacturer = $ComputerInfo.CsManufacturer


#region DEVICEINVENTORY

if ($CollectDeviceInventory) {

#Set Name of Log

$DeviceLog = "PowerStacksDeviceInventory"


# Get Computer Inventory Information

$ComputerPhysicalMemory = $ComputerInfo.CsTotalPhysicalMemory

$ComputerNumberOfProcessors = (Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction SilentlyContinue).NumberOfProcessors

$ComputerCPU = Get-CimInstance win32_processor -ErrorAction SilentlyContinue | Select-Object Manufacturer, Name, MaxClockSpeed, NumberOfCores, NumberOfLogicalProcessors

$ComputerProcessorManufacturer = $ComputerCPU.Manufacturer | Get-Unique

$ComputerProcessorName = $ComputerCPU.Name | Get-Unique

$ComputerProcessorMaxClockSpeed = $ComputerCPU.MaxClockSpeed | Get-Unique

$ComputerNumberOfCores = $ComputerCPU.NumberOfCores | Get-Unique

$ComputerNumberOfLogicalProcessors = $ComputerCPU.NumberOfLogicalProcessors | Get-Unique


$BatteryDesignedCapacity = (Get-WmiObject -Class "BatteryStaticData" -Namespace "ROOT\WMI" -ErrorAction SilentlyContinue).DesignedCapacity

$BatteryFullChargedCapacity = (Get-WmiObject -Class "BatteryFullChargedCapacity" -Namespace "ROOT\WMI" -ErrorAction SilentlyContinue).FullChargedCapacity

#$timestamp = Get-Date -Format "yyyy-MM-DDThh:mm:ssZ"


#Grab Built-in Monitors PNPDeviceID

$BuiltInMonitors = Get-CimInstance Win32_DesktopMonitor | Select-Object PNPDeviceID -ErrorAction SilentlyContinue


#Grabs the Monitor objects from WMI

$Monitors = Get-WmiObject -Namespace "root\WMI" -Class "WMIMonitorID" -ErrorAction SilentlyContinue


#Creates an empty array to hold the data

$MonitorArray = @()


#Takes each monitor object found and runs the following code:

foreach ($Monitor in $Monitors) {


if(-Not($Monitor.InstanceName.Substring(0,$Monitor.InstanceName.LastIndexOf('_')) -in $BuiltInMonitors.PNPDeviceID)){


#Grabs respective data and converts it from ASCII encoding and removes any trailing ASCII null values


if ([System.Text.Encoding]::ASCII.GetString($Monitor.UserFriendlyName) -ne $null) {

$MonitorModel = ([System.Text.Encoding]::ASCII.GetString($Monitor.UserFriendlyName)).Replace("$([char]0x0000)","")

} else {

$MonitorModel = $null

}


$MonitorSerialNumber = ([System.Text.Encoding]::ASCII.GetString($Monitor.SerialNumberID)).Replace("$([char]0x0000)","")

$MonitorManufacturer = ([System.Text.Encoding]::ASCII.GetString($Monitor.ManufacturerName)).Replace("$([char]0x0000)","")

$MonitorWeekOfManufacture = $Monitor.WeekOfManufacture

$MonitorYearOfManufacture = $Monitor.YearOfManufacture


$tempmonitor = New-Object -TypeName PSObject

$tempmonitor | Add-Member -MemberType NoteProperty -Name "Manufacturer" -Value "$MonitorManufacturer" -Force

$tempmonitor | Add-Member -MemberType NoteProperty -Name "Model" -Value "$MonitorModel" -Force

$tempmonitor | Add-Member -MemberType NoteProperty -Name "SerialNumber" -Value "$MonitorSerialNumber" -Force

$tempmonitor | Add-Member -MemberType NoteProperty -Name "WeekOfManufacture" -Value "$MonitorWeekOfManufacture" -Force

$tempmonitor | Add-Member -MemberType NoteProperty -Name "YearOfManufacture" -Value "$MonitorYearOfManufacture" -Force

$MonitorArray += $tempmonitor

}

}

[System.Collections.ArrayList]$MonitorArrayList = $MonitorArray



# Create JSON to Upload to Log Analytics

$Inventory = New-Object System.Object

$Inventory | Add-Member -MemberType NoteProperty -Name "Memory" -Value "$ComputerPhysicalMemory" -Force

$Inventory | Add-Member -MemberType NoteProperty -Name "CPUManufacturer" -Value "$ComputerProcessorManufacturer" -Force

$Inventory | Add-Member -MemberType NoteProperty -Name "CPUName" -Value "$ComputerProcessorName" -Force

$Inventory | Add-Member -MemberType NoteProperty -Name "CPUMaxClockSpeed" -Value "$ComputerProcessorMaxClockSpeed" -Force

$Inventory | Add-Member -MemberType NoteProperty -Name "CPUPhysical" -Value "$ComputerNumberOfProcessors" -Force

$Inventory | Add-Member -MemberType NoteProperty -Name "CPUCores" -Value "$ComputerNumberOfCores" -Force

$Inventory | Add-Member -MemberType NoteProperty -Name "CPULogical" -Value "$ComputerNumberOfLogicalProcessors" -Force

$Inventory | Add-Member -MemberType NoteProperty -Name "BatteryDesignedCapacity" -Value "$BatteryDesignedCapacity" -Force

$Inventory | Add-Member -MemberType NoteProperty -Name "BatteryFullChargedCapacity" -Value "$BatteryFullChargedCapacity" -Force

$Inventory | Add-Member -MemberType NoteProperty -Name "Monitors" -Value $MonitorArrayList -Force


$DeviceDetailsJson = $Inventory | ConvertTo-Json


$ms = New-Object System.IO.MemoryStream

$cs = New-Object System.IO.Compression.GZipStream($ms, [System.IO.Compression.CompressionMode]::Compress)

$sw = New-Object System.IO.StreamWriter($cs)

$sw.Write($DeviceDetailsJson)

$sw.Close();

$DeviceDetailsJson = [System.Convert]::ToBase64String($ms.ToArray())


$MainDevice = New-Object -TypeName PSObject

$MainDevice | Add-Member -MemberType NoteProperty -Name "ComputerName" -Value "$ComputerName" -Force

$MainDevice | Add-Member -MemberType NoteProperty -Name "ManagedDeviceID" -Value "$ManagedDeviceID" -Force


$DeviceDetailsJsonArr = $DeviceDetailsJson -split '(.{31744})'


$i = 0


foreach ($DeviceDetails in $DeviceDetailsJsonArr) {


if ($DeviceDetails.Length -gt 0 ){

$i++

$MainDevice | Add-Member -MemberType NoteProperty -Name ("DeviceDetails" + $i.ToString()) -Value $DeviceDetails -Force

}


}

if ($DeviceDetailsJson.Length -gt (10 * 31 * 1024))

{

throw("DeviceDetails is too big and exceed the 32kb limit per column for a single upload. Please increase number of columns (#10). Current payload size is: " + ($DeviceDetailsJson.Length/1024).ToString("#.#") + "kb")

}


$DeviceJson = $MainDevice | ConvertTo-Json


# Submit the data to the API endpoint

$ResponseDeviceInventory = Send-LogAnalyticsData -customerId $customerId -sharedKey $sharedKey -body ([System.Text.Encoding]::UTF8.GetBytes($DeviceJson)) -logType $DeviceLog

}

#endregion DEVICEINVENTORY


#region APPINVENTORY

if ($CollectAppInventory) {

#Set Name of Log

$AppLog = "PowerStacksAppInventory"


#Get SID of current interactive users

$CurrentLoggedOnUser = (Get-WmiObject -Class win32_computersystem).UserName

$AdObj = New-Object System.Security.Principal.NTAccount($CurrentLoggedOnUser)

$strSID = $AdObj.Translate([System.Security.Principal.SecurityIdentifier])

$UserSid = $strSID.Value

#Get Apps for system and current user

$MyApps = Get-InstalledApplications -UserSid $UserSid

$UniqueApps = ($MyApps | Group-Object Displayname | Where-Object { $_.Count -eq 1 } ).Group

$DuplicatedApps = ($MyApps | Group-Object Displayname | Where-Object { $_.Count -gt 1 } ).Group

$NewestDuplicateApp = ($DuplicatedApps | Group-Object DisplayName) | ForEach-Object { $_.Group | Sort-Object [version]DisplayVersion -Descending | Select-Object -First 1 }

$CleanAppList = $UniqueApps + $NewestDuplicateApp | Sort-Object DisplayName


$AppArray = @()

foreach ($App in $CleanAppList) {

$tempapp = New-Object -TypeName PSObject

$tempapp | Add-Member -MemberType NoteProperty -Name "AppName" -Value $App.DisplayName -Force

$tempapp | Add-Member -MemberType NoteProperty -Name "AppVersion" -Value $App.DisplayVersion -Force

$tempapp | Add-Member -MemberType NoteProperty -Name "AppInstallDate" -Value $App.InstallDate -Force -ErrorAction SilentlyContinue

$tempapp | Add-Member -MemberType NoteProperty -Name "AppPublisher" -Value $App.Publisher -Force

$tempapp | Add-Member -MemberType NoteProperty -Name "AppUninstallString" -Value $App.UninstallString -Force

$tempapp | Add-Member -MemberType NoteProperty -Name "AppUninstallRegPath" -Value $app.PSPath.Split("::")[-1]

$AppArray += $tempapp

}

$InstalledAppJson = $AppArray | ConvertTo-Json


$ms = New-Object System.IO.MemoryStream

$cs = New-Object System.IO.Compression.GZipStream($ms, [System.IO.Compression.CompressionMode]::Compress)

$sw = New-Object System.IO.StreamWriter($cs)

$sw.Write($InstalledAppJson)

$sw.Close();

$InstalledAppJson = [System.Convert]::ToBase64String($ms.ToArray())


$MainApp = New-Object -TypeName PSObject

$MainApp | Add-Member -MemberType NoteProperty -Name "ComputerName" -Value "$ComputerName" -Force

$MainApp | Add-Member -MemberType NoteProperty -Name "ManagedDeviceID" -Value "$ManagedDeviceID" -Force


$InstalledAppJsonArr = $InstalledAppJson -split '(.{31744})'


$i = 0


foreach ($InstalledApp in $InstalledAppJsonArr) {


if ($InstalledApp.Length -gt 0 ){

$i++

$MainApp | Add-Member -MemberType NoteProperty -Name ("InstalledApps" + $i.ToString()) -Value $InstalledApp -Force

}


}

if ($InstalledAppJson.Length -gt (10 * 31 * 1024))

{

throw("InstallApp is too big and exceed the 32kb limit per column for a single upload. Please increase number of columns (#10). Current payload size is: " + ($InstalledAppJson.Length/1024).ToString("#.#") + "kb")

}


$AppJson = $MainApp | ConvertTo-Json


# Submit the data to the API endpoint

$ResponseAppInventory = Send-LogAnalyticsData -customerId $customerId -sharedKey $sharedKey -body ([System.Text.Encoding]::UTF8.GetBytes($AppJson)) -logType $AppLog

}

#endregion APPINVENTORY


#Report back status

$date = Get-Date -Format "dd-MM HH:mm"

$OutputMessage = "InventoryDate: $date "


if ($CollectDeviceInventory) {

if ($ResponseDeviceInventory -match "200 :") {

$OutputMessage = $OutPutMessage + "DeviceInventory: OK " + $ResponseDeviceInventory

}

else {

$OutputMessage = $OutPutMessage + "DeviceInventory: Fail "

}

}

if ($CollectAppInventory) {

if ($ResponseAppInventory -match "200 :") {

$OutputMessage = $OutPutMessage + " AppInventory: OK " + $ResponseAppInventory

}

else {

$OutputMessage = $OutPutMessage + " AppInventory: Fail "

}

}

Write-Output $OutputMessage

Exit 0




#endregion script

Next configure the BI for Intune workspace to report on the software inventory data as described in the Configure the Workspace for Software Inventory documentation.