Print

Many customers have requested the ability to report on things that are either not collected or not accurately collected by Intune. In an effort to fill these gaps in we have implemented a custom solution to collect some of the most commonly requested items. Currently this list includes:

  1. Software installed on Windows 10 devices.
  2. Monitors (LCD’s) connected to Windows 10 devices.
  3. Disk health.
  4. Last Reboot time.
  5. Additional CPU details.
  6. Additional RAM details.
  7. Battery health.

This data is collected via PowerShell, sent to a Log Analytics workspace, and then pulled into Power BI. Most customers leverage a Proactive Remediation detection to run the PowerShell script on a defined schedule. In this document we describe how to configure the script and deploy it using a Proactive Remediation however there are many ways that you might run a script on a schedule. 

You can copy the PowerShell script from below. 

PowerShell
<# Original script credit to Jan Ketil Skanke, Sandy Zeng and Maurice Daly

.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 Powerstacks 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 = "<ENTER YOUR LOG ANALYTICS WORKSPACE ID>"

# Replace with your Primary Key
$SharedKey = "<ENTER YOUR LOG ANALYTICS PRIMARY KEY HERE>"

#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
$ComputerLastBootUpTime = $ComputerInfo.OsLastBootUpTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ")
$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


# Obtain physical disk details
$Disks = Get-PhysicalDisk | Where-Object { $_.BusType -match "NVMe|SATA|SAS|ATAPI|RAID" } | Select-Object -Property DeviceId, BusType, FirmwareVersion, HealthStatus, Manufacturer, Model, FriendlyName, SerialNumber, Size, MediaType

#Creates an empty array to hold the data
$DiskArray = @()

foreach ($Disk in ($Disks | Sort-Object DeviceID)) {

# Obtain disk health information from current disk
$DiskHealth = Get-PhysicalDisk | Where-Object { $_.DeviceId -eq $Disk.DeviceID } | Get-StorageReliabilityCounter | Select-Object -Property Wear, ReadErrorsTotal, ReadErrorsUncorrected, WriteErrorsTotal, WriteErrorsUncorrected, Temperature, TemperatureMax

# Obtain SMART failure information
$DrivePNPDeviceID = (Get-WmiObject -Class Win32_DiskDrive | Where-Object { $_.Index -eq $Disk.DeviceID }).PNPDeviceID
$DriveSMARTStatus = (Get-WmiObject -namespace root\wmi -class MSStorageDriver_FailurePredictStatus -ErrorAction SilentlyContinue | Select-Object PredictFailure, Reason) | Where-Object { $_.InstanceName -eq $DrivePNPDeviceID }

# Create custom PSObject
$tempdisk = new-object -TypeName PSObject

# Create disk health state entry
$tempdisk | Add-Member -MemberType NoteProperty -Name "Number" -Value $Disk.DeviceID
$tempdisk | Add-Member -MemberType NoteProperty -Name "BusType" -Value $Disk.BusType
$tempdisk | Add-Member -MemberType NoteProperty -Name "FirmwareVersion" -Value $Disk.FirmwareVersion
$tempdisk | Add-Member -MemberType NoteProperty -Name "HealthStatus" -Value $Disk.HealthStatus
$tempdisk | Add-Member -MemberType NoteProperty -Name "Manufacturer" -Value $Disk.Manufacturer
$tempdisk | Add-Member -MemberType NoteProperty -Name "Model" -Value $Disk.Model
$tempdisk | Add-Member -MemberType NoteProperty -Name "Name" -Value $Disk.FriendlyName
$tempdisk | Add-Member -MemberType NoteProperty -Name "SerialNumber" -Value $Disk.SerialNumber
$tempdisk | Add-Member -MemberType NoteProperty -Name "Size" -Value $Disk.Size
$tempdisk | Add-Member -MemberType NoteProperty -Name "Type" -Value $Disk.MediaType
$tempdisk | Add-Member -MemberType NoteProperty -Name "SMARTPredictFailure" -Value $DriveSMARTStatus.PredictFailure
$tempdisk | Add-Member -MemberType NoteProperty -Name "SMARTReason" -Value $DriveSMARTStatus.Reason
$tempdisk | Add-Member -MemberType NoteProperty -Name "Wear" -Value $([int]($DiskHealth.Wear))
$tempdisk | Add-Member -MemberType NoteProperty -Name "ReadErrorsUncorrected" -Value $DiskHealth.ReadErrorsUncorrected
$tempdisk | Add-Member -MemberType NoteProperty -Name "ReadErrorsTotal" -Value $DiskHealth.ReadErrorsTotal
$tempdisk | Add-Member -MemberType NoteProperty -Name "WriteErrorsUncorrected" -Value $DiskHealth.WriteErrorsUncorrected
$tempdisk | Add-Member -MemberType NoteProperty -Name "WriteErrorsTotal" -Value $DiskHealth.WriteErrorsTotal
$tempdisk | Add-Member -MemberType NoteProperty -Name "Temperature" -Value $([int]($DiskHealth.Temperature))
$tempdisk | Add-Member -MemberType NoteProperty -Name "TemperatureMax" -Value $([int]($DiskHealth.TemperatureMax))

$DiskArray += $tempdisk
}
[System.Collections.ArrayList]$DiskArrayList = $DiskArray


# 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
$Inventory | Add-Member -MemberType NoteProperty -Name "LastBootTime" -Value "$ComputerLastBootUpTime" -Force
$Inventory | Add-Member -MemberType NoteProperty -Name "PhysicalDisks" -Value $DiskArrayList -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
if ($CurrentLoggedOnUser -eq $null){
    $CurrentOwner = Get-CimInstance Win32_Process -Filter 'name = "explorer.exe"' | Invoke-CimMethod -MethodName getowner
    $CurrentLoggedOnUser = "$($CurrentOwner.Domain)\$($CurrentOwner.User)"
}
$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).ToUniversalTime().ToString("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