Check devices seen from users in EntraID
A quick script to pull all the devices seen in use, from users, in the last XX days across your MS365 estate.
As always, please test these scripts in your own environment. Cyber Tec Security do not warrant these scripts in any way; the use of these is solely your responsibility.
Copy the PowerShell script below, save it somewhere you know where to find it, and use the command below to execute it. This will create multiple CSV files covering:
- CollectionIssues (any trouble the script had whilst running)
- MAMAppRegistrations
- SinInOnlyObservations
- UnifiedDeviceInventory (CSV and XLS)
powershell -executionpolicy Bypass -File .\Export-EntraDeviceDiscovery.ps1 -TenantID "your tenant ID.onmicrosoft.com" -SignInLookbackDays 30 -OutputDirectory "c:\temp\DeviceInventory"
<#
.SYNOPSIS
Creates a unified Microsoft Entra, Intune MDM, Intune MAM and sign-in device inventory.
Use this script at your own risk. Cyber Tec Security accepts no liability and provides no warranty.
.DESCRIPTION
Installs required PowerShell modules, authenticates interactively to Microsoft Graph,
and builds a consolidated inventory from:
- Microsoft Entra registered device objects
- Registered owners for each Entra device
- Intune managed devices (MDM), when available
- Intune managed app registrations (MAM), when available
- Microsoft Entra sign-in logs, when available
The script is deliberately resilient. Optional Graph properties are read safely and
failure of one data source does not stop the remaining report.
Outputs:
- UnifiedDeviceInventory.csv
- MamAppRegistrations.csv
- SignInOnlyObservations.csv
- CollectionIssues.csv
- UnifiedDeviceInventory.xlsx (when ImportExcel is available)
.NOTES
Recommended PowerShell: PowerShell 7+
Delegated Microsoft Graph permissions requested:
Device.Read.All
Directory.Read.All
User.Read.All
DeviceManagementManagedDevices.Read.All
DeviceManagementApps.Read.All
AuditLog.Read.All
Microsoft Entra roles and Intune RBAC still apply. An administrator may need to
grant consent. Sign-in log access and retention depend on tenant licensing.
.EXAMPLE
.\Export-EntraDeviceDiscovery.ps1 `
-TenantId "cybertecsecurity.onmicrosoft.com" `
-SignInLookbackDays 14
.EXAMPLE
.\Export-EntraDeviceDiscovery.ps1 `
-TenantId "00000000-0000-0000-0000-000000000000" `
-OutputDirectory "C:\CTS-Reports\DeviceInventory" `
-SignInLookbackDays 30
#>
[CmdletBinding()]
param(
[Parameter()]
[string]$TenantId,
[Parameter()]
[ValidateRange(1, 30)]
[int]$SignInLookbackDays = 14,
[Parameter()]
[string]$OutputDirectory = (
Join-Path -Path $PWD -ChildPath (
"UnifiedDeviceInventory_{0}" -f (Get-Date -Format "yyyyMMdd_HHmmss")
)
),
[Parameter()]
[switch]$SkipModuleInstallation,
[Parameter()]
[switch]$SkipExcel,
[Parameter()]
[switch]$IncludeFailedSignIns
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProgressPreference = "Continue"
$script:CollectionIssues = [System.Collections.Generic.List[object]]::new()
function Write-Section {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Message
)
Write-Host ""
Write-Host ("=== {0} ===" -f $Message) -ForegroundColor Cyan
}
function Add-CollectionIssue {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Source,
[Parameter(Mandatory)]
[string]$Message,
[Parameter()]
[string]$ObjectId,
[Parameter()]
[ValidateSet("Information", "Warning", "Error")]
[string]$Severity = "Warning"
)
$script:CollectionIssues.Add(
[pscustomobject][ordered]@{
Timestamp = Get-Date
Severity = $Severity
Source = $Source
ObjectId = $ObjectId
Message = $Message
}
)
}
function Get-SafeProperty {
[CmdletBinding()]
param(
[Parameter()]
[AllowNull()]
[object]$InputObject,
[Parameter(Mandatory)]
[string]$Name,
[Parameter()]
[AllowNull()]
$DefaultValue = $null
)
if ($null -eq $InputObject) {
return $DefaultValue
}
if ($InputObject -is [System.Collections.IDictionary]) {
if ($InputObject.Contains($Name)) {
return $InputObject[$Name]
}
return $DefaultValue
}
$property = $InputObject.PSObject.Properties[$Name]
if ($null -eq $property) {
return $DefaultValue
}
return $property.Value
}
function Get-NestedProperty {
[CmdletBinding()]
param(
[Parameter()]
[AllowNull()]
[object]$InputObject,
[Parameter(Mandatory)]
[string[]]$Path,
[Parameter()]
[AllowNull()]
$DefaultValue = $null
)
$current = $InputObject
foreach ($segment in $Path) {
$current = Get-SafeProperty -InputObject $current -Name $segment -DefaultValue $null
if ($null -eq $current) {
return $DefaultValue
}
}
return $current
}
function Convert-ToLocalDateTime {
[CmdletBinding()]
param(
[Parameter()]
[AllowNull()]
$Value
)
if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) {
return $null
}
try {
return ([datetimeoffset]$Value).ToLocalTime().DateTime
}
catch {
return $null
}
}
function Get-LatestDateTime {
[CmdletBinding()]
param(
[Parameter()]
[AllowEmptyCollection()]
[object[]]$Values
)
$validValues = @(
foreach ($value in $Values) {
$converted = Convert-ToLocalDateTime -Value $value
if ($null -ne $converted) {
$converted
}
}
)
if ($validValues.Count -eq 0) {
return $null
}
return $validValues | Sort-Object -Descending | Select-Object -First 1
}
function Convert-ToBooleanOrNull {
[CmdletBinding()]
param(
[Parameter()]
[AllowNull()]
$Value
)
if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) {
return $null
}
if ($Value -is [bool]) {
return $Value
}
$parsed = $false
if ([bool]::TryParse([string]$Value, [ref]$parsed)) {
return $parsed
}
return $null
}
function Convert-ToText {
[CmdletBinding()]
param(
[Parameter()]
[AllowNull()]
$Value,
[Parameter()]
[string]$Separator = "; "
)
if ($null -eq $Value) {
return $null
}
if ($Value -is [string]) {
return $Value
}
if ($Value -is [System.Collections.IEnumerable]) {
return (@($Value) | ForEach-Object { [string]$_ }) -join $Separator
}
return [string]$Value
}
function Get-ObjectFingerprint {
[CmdletBinding()]
param(
[Parameter()]
[AllowNull()]
[object]$InputObject
)
if ($null -eq $InputObject) {
return $null
}
try {
return $InputObject | ConvertTo-Json -Depth 10 -Compress
}
catch {
return [string]$InputObject
}
}
function Ensure-PackageProvider {
[CmdletBinding()]
param()
if ($PSVersionTable.PSVersion.Major -ge 7) {
return
}
[Net.ServicePointManager]::SecurityProtocol = `
[Net.ServicePointManager]::SecurityProtocol -bor `
[Net.SecurityProtocolType]::Tls12
if (-not (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) {
Install-PackageProvider `
-Name NuGet `
-MinimumVersion "2.8.5.201" `
-Scope CurrentUser `
-Force | Out-Null
}
}
function Ensure-Module {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Name,
[Parameter()]
[switch]$Optional
)
$available = Get-Module -ListAvailable -Name $Name |
Sort-Object Version -Descending |
Select-Object -First 1
if ($null -eq $available) {
if ($SkipModuleInstallation) {
if ($Optional) {
Add-CollectionIssue `
-Source "Module" `
-Severity "Information" `
-Message ("Optional module '{0}' is not installed." -f $Name)
return $false
}
throw "Required module '$Name' is not installed and -SkipModuleInstallation was supplied."
}
try {
Ensure-PackageProvider
Write-Host ("Installing PowerShell module {0}..." -f $Name)
Install-Module `
-Name $Name `
-Repository PSGallery `
-Scope CurrentUser `
-Force `
-AllowClobber
}
catch {
if ($Optional) {
Add-CollectionIssue `
-Source "Module" `
-Message ("Could not install optional module '{0}': {1}" -f $Name, $_.Exception.Message)
return $false
}
throw
}
}
try {
Import-Module -Name $Name -Force
return $true
}
catch {
if ($Optional) {
Add-CollectionIssue `
-Source "Module" `
-Message ("Could not import optional module '{0}': {1}" -f $Name, $_.Exception.Message)
return $false
}
throw
}
}
function Get-AllGraphResults {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Uri,
[Parameter()]
[string]$Source = "Microsoft Graph"
)
$results = [System.Collections.Generic.List[object]]::new()
$nextLink = $Uri
$pageNumber = 0
while (-not [string]::IsNullOrWhiteSpace($nextLink)) {
$pageNumber++
try {
$response = Invoke-MgGraphRequest `
-Method GET `
-Uri $nextLink `
-OutputType PSObject
}
catch {
throw ("{0} failed on page {1}: {2}" -f $Source, $pageNumber, $_.Exception.Message)
}
$value = Get-SafeProperty -InputObject $response -Name "value" -DefaultValue $null
if ($null -ne $value) {
foreach ($item in @($value)) {
$results.Add($item)
}
}
elseif ($null -ne $response) {
$results.Add($response)
}
$nextLink = [string](
Get-SafeProperty -InputObject $response -Name "@odata.nextLink" -DefaultValue $null
)
}
return @($results)
}
function Get-EntraDevices {
[CmdletBinding()]
param()
Write-Section "Retrieving Entra registered devices"
$select = @(
"id"
"deviceId"
"displayName"
"operatingSystem"
"operatingSystemVersion"
"trustType"
"profileType"
"accountEnabled"
"isManaged"
"isCompliant"
"deviceOwnership"
"registrationDateTime"
"approximateLastSignInDateTime"
"manufacturer"
"model"
"mdmAppId"
) -join ","
$uri = "https://graph.microsoft.com/v1.0/devices?`$select=$select&`$top=999"
$devices = @(Get-AllGraphResults -Uri $uri -Source "Entra devices")
Write-Host ("Retrieved {0} Entra device object(s)." -f $devices.Count) -ForegroundColor Green
return $devices
}
function Get-EntraDeviceOwnerLookup {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object[]]$Devices
)
Write-Section "Retrieving registered owner for each Entra device"
$lookup = @{}
$total = [Math]::Max($Devices.Count, 1)
$position = 0
foreach ($device in $Devices) {
$position++
$directoryObjectId = [string](Get-SafeProperty -InputObject $device -Name "id")
$displayName = [string](Get-SafeProperty -InputObject $device -Name "displayName")
if ([string]::IsNullOrWhiteSpace($directoryObjectId)) {
continue
}
Write-Progress `
-Activity "Reading Entra registered owners" `
-Status ("{0} of {1}: {2}" -f $position, $Devices.Count, $displayName) `
-PercentComplete (($position / $total) * 100)
$uri = (
"https://graph.microsoft.com/v1.0/devices/{0}/registeredOwners/" +
"microsoft.graph.user?`$select=id,displayName,userPrincipalName,accountEnabled,userType"
) -f $directoryObjectId
try {
$owners = @(Get-AllGraphResults -Uri $uri -Source "Entra registered owners")
$lookup[$directoryObjectId] = $owners
}
catch {
$statusText = $_.Exception.Message
# A missing owner or inaccessible relationship should not stop the report.
$lookup[$directoryObjectId] = @()
Add-CollectionIssue `
-Source "Entra registered owner" `
-ObjectId $directoryObjectId `
-Message ("Could not retrieve owner for device '{0}': {1}" -f $displayName, $statusText)
}
}
Write-Progress -Activity "Reading Entra registered owners" -Completed
return $lookup
}
function Get-IntuneManagedDevices {
[CmdletBinding()]
param()
Write-Section "Retrieving Intune MDM devices"
$select = @(
"id"
"azureADDeviceId"
"deviceName"
"userId"
"userDisplayName"
"userPrincipalName"
"emailAddress"
"operatingSystem"
"osVersion"
"manufacturer"
"model"
"serialNumber"
"managedDeviceOwnerType"
"managementAgent"
"complianceState"
"enrolledDateTime"
"lastSyncDateTime"
"deviceEnrollmentType"
"jailBroken"
"partnerReportedThreatState"
"wiFiMacAddress"
"ethernetMacAddress"
) -join ","
$uri = (
"https://graph.microsoft.com/v1.0/deviceManagement/managedDevices" +
"?`$select=$select&`$top=999"
)
try {
$devices = @(Get-AllGraphResults -Uri $uri -Source "Intune managed devices")
Write-Host ("Retrieved {0} Intune MDM device record(s)." -f $devices.Count) -ForegroundColor Green
return $devices
}
catch {
Add-CollectionIssue `
-Source "Intune MDM" `
-Message $_.Exception.Message
Write-Warning "Intune MDM data is unavailable. The remaining report will continue."
return @()
}
}
function Get-IntuneMamRegistrations {
[CmdletBinding()]
param()
Write-Section "Retrieving Intune MAM app registrations"
$uri = (
"https://graph.microsoft.com/v1.0/deviceAppManagement/managedAppRegistrations" +
"?`$top=999"
)
try {
$registrations = @(Get-AllGraphResults -Uri $uri -Source "Intune MAM registrations")
Write-Host ("Retrieved {0} MAM registration record(s)." -f $registrations.Count) -ForegroundColor Green
return $registrations
}
catch {
Add-CollectionIssue `
-Source "Intune MAM" `
-Message $_.Exception.Message
Write-Warning "Intune MAM data is unavailable. The remaining report will continue."
return @()
}
}
function Get-EntraUsers {
[CmdletBinding()]
param()
Write-Section "Retrieving Entra users for identity enrichment"
$uri = (
"https://graph.microsoft.com/v1.0/users" +
"?`$select=id,displayName,userPrincipalName,accountEnabled,userType&`$top=999"
)
try {
$users = @(Get-AllGraphResults -Uri $uri -Source "Entra users")
Write-Host ("Retrieved {0} Entra user object(s)." -f $users.Count) -ForegroundColor Green
return $users
}
catch {
Add-CollectionIssue -Source "Entra users" -Message $_.Exception.Message
Write-Warning "User enrichment is unavailable. IDs will be retained where possible."
return @()
}
}
function Get-EntraSignIns {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[int]$LookbackDays,
[Parameter()]
[switch]$IncludeFailures
)
Write-Section "Retrieving recent Entra sign-in observations"
$startUtc = (Get-Date).ToUniversalTime().AddDays(-$LookbackDays)
$startValue = $startUtc.ToString("yyyy-MM-ddTHH:mm:ssZ")
$filterText = "createdDateTime ge $startValue"
if (-not $IncludeFailures) {
$filterText = "$filterText and status/errorCode eq 0"
}
$filter = [uri]::EscapeDataString($filterText)
$select = @(
"id"
"createdDateTime"
"userId"
"userDisplayName"
"userPrincipalName"
"appDisplayName"
"clientAppUsed"
"ipAddress"
"deviceDetail"
"status"
"conditionalAccessStatus"
"isInteractive"
"resourceDisplayName"
) -join ","
$uri = (
"https://graph.microsoft.com/v1.0/auditLogs/signIns" +
"?`$filter=$filter&`$select=$select&`$top=1000"
)
try {
$signIns = @(Get-AllGraphResults -Uri $uri -Source "Entra sign-ins")
Write-Host (
"Retrieved {0} sign-in record(s) from the last {1} day(s)." -f
$signIns.Count,
$LookbackDays
) -ForegroundColor Green
return $signIns
}
catch {
Add-CollectionIssue -Source "Entra sign-ins" -Message $_.Exception.Message
Write-Warning "Sign-in observations are unavailable. The remaining report will continue."
return @()
}
}
function Convert-MamRegistration {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object]$Registration,
[Parameter(Mandatory)]
[hashtable]$UserLookup
)
$userId = [string](Get-SafeProperty -InputObject $Registration -Name "userId")
$user = $null
if (-not [string]::IsNullOrWhiteSpace($userId) -and $UserLookup.ContainsKey($userId)) {
$user = $UserLookup[$userId]
}
$appIdentifier = Get-SafeProperty -InputObject $Registration -Name "appIdentifier"
$appIdentity = $null
foreach ($candidateName in @("packageId", "bundleId", "applicationId", "appId")) {
$candidate = Get-SafeProperty -InputObject $appIdentifier -Name $candidateName
if (-not [string]::IsNullOrWhiteSpace([string]$candidate)) {
$appIdentity = [string]$candidate
break
}
}
if ([string]::IsNullOrWhiteSpace($appIdentity)) {
if ($appIdentifier -is [string]) {
$appIdentity = [string]$appIdentifier
}
elseif ($null -ne $appIdentifier) {
$appIdentity = Get-ObjectFingerprint -InputObject $appIdentifier
}
}
$odataType = [string](Get-SafeProperty -InputObject $Registration -Name "@odata.type")
$deviceType = [string](Get-SafeProperty -InputObject $Registration -Name "deviceType")
if ([string]::IsNullOrWhiteSpace($deviceType) -and -not [string]::IsNullOrWhiteSpace($odataType)) {
$deviceType = $odataType -replace '^#microsoft\.graph\.', '' -replace 'ManagedAppRegistration$', ''
}
return [pscustomobject][ordered]@{
UserId = $userId
UserDisplayName = if ($null -ne $user) { Get-SafeProperty $user "displayName" } else { $null }
UserPrincipalName = if ($null -ne $user) { Get-SafeProperty $user "userPrincipalName" } else { $null }
UserEnabled = if ($null -ne $user) { Get-SafeProperty $user "accountEnabled" } else { $null }
UserType = if ($null -ne $user) { Get-SafeProperty $user "userType" } else { $null }
DeviceName = Get-SafeProperty $Registration "deviceName"
DeviceType = $deviceType
PlatformVersion = Get-SafeProperty $Registration "platformVersion"
ApplicationIdentifier = $appIdentity
ApplicationVersion = Get-SafeProperty $Registration "applicationVersion"
ManagementSdkVersion = Get-SafeProperty $Registration "managementSdkVersion"
FirstRegistered = Convert-ToLocalDateTime (Get-SafeProperty $Registration "createdDateTime")
LastMamSync = Convert-ToLocalDateTime (Get-SafeProperty $Registration "lastSyncDateTime")
FlaggedReasons = Convert-ToText (Get-SafeProperty $Registration "flaggedReasons")
DeviceTag = Get-SafeProperty $Registration "deviceTag"
RegistrationType = $odataType
MamRegistrationId = Get-SafeProperty $Registration "id"
RawAppIdentifier = Get-ObjectFingerprint $appIdentifier
}
}
function Get-SourceLabel {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[bool]$HasEntra,
[Parameter(Mandatory)]
[bool]$HasMdm,
[Parameter(Mandatory)]
[bool]$HasMam,
[Parameter(Mandatory)]
[bool]$HasSignIn
)
$parts = [System.Collections.Generic.List[string]]::new()
if ($HasEntra) { $parts.Add("Entra") }
if ($HasMdm) { $parts.Add("Intune MDM") }
if ($HasMam) { $parts.Add("Intune MAM") }
if ($HasSignIn) { $parts.Add("Sign-in") }
if ($parts.Count -eq 0) {
return "Unknown"
}
return $parts -join " + "
}
function Get-InventoryClassification {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[bool]$HasEntra,
[Parameter(Mandatory)]
[bool]$HasMdm,
[Parameter(Mandatory)]
[bool]$HasMam,
[Parameter(Mandatory)]
[bool]$HasSignIn
)
if ($HasMdm) {
return "Intune managed"
}
if ($HasEntra -and $HasMam) {
return "Entra registered + MAM protected"
}
if ($HasMam) {
return "MAM only"
}
if ($HasEntra) {
return "Entra registered - unmanaged"
}
if ($HasSignIn) {
return "Sign-in only"
}
return "Unknown"
}
function New-UnifiedInventory {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object[]]$EntraDevices,
[Parameter(Mandatory)]
[hashtable]$OwnerLookup,
[Parameter(Mandatory)]
[object[]]$MdmDevices,
[Parameter(Mandatory)]
[object[]]$MamRows,
[Parameter(Mandatory)]
[object[]]$SignIns,
[Parameter(Mandatory)]
[hashtable]$UserLookup
)
Write-Section "Correlating device sources"
$inventory = [System.Collections.Generic.List[object]]::new()
$matchedMamIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$matchedSignInIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$mdmByEntraDeviceId = @{}
foreach ($mdm in $MdmDevices) {
$key = [string](Get-SafeProperty $mdm "azureADDeviceId")
if (-not [string]::IsNullOrWhiteSpace($key)) {
$mdmByEntraDeviceId[$key.ToLowerInvariant()] = $mdm
}
}
$mamByUserId = @{}
foreach ($mam in $MamRows) {
$key = [string](Get-SafeProperty $mam "UserId")
if ([string]::IsNullOrWhiteSpace($key)) {
continue
}
if (-not $mamByUserId.ContainsKey($key)) {
$mamByUserId[$key] = [System.Collections.Generic.List[object]]::new()
}
$mamByUserId[$key].Add($mam)
}
$signInsByDeviceId = @{}
foreach ($signIn in $SignIns) {
$deviceId = [string](Get-NestedProperty $signIn @("deviceDetail", "deviceId"))
if ([string]::IsNullOrWhiteSpace($deviceId)) {
continue
}
$key = $deviceId.ToLowerInvariant()
if (-not $signInsByDeviceId.ContainsKey($key)) {
$signInsByDeviceId[$key] = [System.Collections.Generic.List[object]]::new()
}
$signInsByDeviceId[$key].Add($signIn)
}
foreach ($entra in $EntraDevices) {
$entraDirectoryId = [string](Get-SafeProperty $entra "id")
$entraDeviceId = [string](Get-SafeProperty $entra "deviceId")
$entraDeviceName = [string](Get-SafeProperty $entra "displayName")
$mdm = $null
if (-not [string]::IsNullOrWhiteSpace($entraDeviceId)) {
$mdmKey = $entraDeviceId.ToLowerInvariant()
if ($mdmByEntraDeviceId.ContainsKey($mdmKey)) {
$mdm = $mdmByEntraDeviceId[$mdmKey]
}
}
$deviceSignIns = @()
if (-not [string]::IsNullOrWhiteSpace($entraDeviceId)) {
$signInKey = $entraDeviceId.ToLowerInvariant()
if ($signInsByDeviceId.ContainsKey($signInKey)) {
$deviceSignIns = @($signInsByDeviceId[$signInKey])
}
}
$latestSignIn = $deviceSignIns |
Sort-Object { Convert-ToLocalDateTime (Get-SafeProperty $_ "createdDateTime") } -Descending |
Select-Object -First 1
foreach ($signIn in $deviceSignIns) {
$signInId = [string](Get-SafeProperty $signIn "id")
if (-not [string]::IsNullOrWhiteSpace($signInId)) {
[void]$matchedSignInIds.Add($signInId)
}
}
$owners = @()
if ($OwnerLookup.ContainsKey($entraDirectoryId)) {
$owners = @($OwnerLookup[$entraDirectoryId])
}
if ($owners.Count -eq 0 -and $null -ne $mdm) {
$mdmUserId = [string](Get-SafeProperty $mdm "userId")
if (-not [string]::IsNullOrWhiteSpace($mdmUserId)) {
$mdmUser = if ($UserLookup.ContainsKey($mdmUserId)) { $UserLookup[$mdmUserId] } else { $null }
$owners = @(
[pscustomobject]@{
id = $mdmUserId
displayName = if ($null -ne $mdmUser) { Get-SafeProperty $mdmUser "displayName" } else { Get-SafeProperty $mdm "userDisplayName" }
userPrincipalName = if ($null -ne $mdmUser) { Get-SafeProperty $mdmUser "userPrincipalName" } else { Get-SafeProperty $mdm "userPrincipalName" }
accountEnabled = if ($null -ne $mdmUser) { Get-SafeProperty $mdmUser "accountEnabled" } else { $null }
userType = if ($null -ne $mdmUser) { Get-SafeProperty $mdmUser "userType" } else { $null }
}
)
}
}
if ($owners.Count -eq 0 -and $null -ne $latestSignIn) {
$signInUserId = [string](Get-SafeProperty $latestSignIn "userId")
$owners = @(
[pscustomobject]@{
id = $signInUserId
displayName = Get-SafeProperty $latestSignIn "userDisplayName"
userPrincipalName = Get-SafeProperty $latestSignIn "userPrincipalName"
accountEnabled = if ($UserLookup.ContainsKey($signInUserId)) { Get-SafeProperty $UserLookup[$signInUserId] "accountEnabled" } else { $null }
userType = if ($UserLookup.ContainsKey($signInUserId)) { Get-SafeProperty $UserLookup[$signInUserId] "userType" } else { $null }
}
)
}
if ($owners.Count -eq 0) {
$owners = @(
[pscustomobject]@{
id = $null
displayName = "(No registered owner)"
userPrincipalName = "(Unassigned)"
accountEnabled = $null
userType = $null
}
)
}
foreach ($owner in $owners) {
$ownerId = [string](Get-SafeProperty $owner "id")
$ownerMamRows = @()
if (-not [string]::IsNullOrWhiteSpace($ownerId) -and $mamByUserId.ContainsKey($ownerId)) {
$candidateMamRows = @($mamByUserId[$ownerId])
# MAM device names are not guaranteed to match Entra names. Prefer a name
# match; otherwise attach MAM only when the user has exactly one candidate.
$nameMatches = @(
$candidateMamRows | Where-Object {
$mamDeviceName = [string](Get-SafeProperty $_ "DeviceName")
-not [string]::IsNullOrWhiteSpace($mamDeviceName) -and
-not [string]::IsNullOrWhiteSpace($entraDeviceName) -and
$mamDeviceName.Equals($entraDeviceName, [System.StringComparison]::OrdinalIgnoreCase)
}
)
if ($nameMatches.Count -gt 0) {
$ownerMamRows = $nameMatches
}
elseif ($candidateMamRows.Count -eq 1) {
$ownerMamRows = $candidateMamRows
}
}
foreach ($mam in $ownerMamRows) {
$mamId = [string](Get-SafeProperty $mam "MamRegistrationId")
if (-not [string]::IsNullOrWhiteSpace($mamId)) {
[void]$matchedMamIds.Add($mamId)
}
}
$latestMam = $ownerMamRows |
Sort-Object { Get-SafeProperty $_ "LastMamSync" } -Descending |
Select-Object -First 1
$hasMdm = $null -ne $mdm
$hasMam = $ownerMamRows.Count -gt 0
$hasSignIn = $null -ne $latestSignIn
$entraLastSeen = Convert-ToLocalDateTime (Get-SafeProperty $entra "approximateLastSignInDateTime")
$mdmLastSeen = if ($hasMdm) { Convert-ToLocalDateTime (Get-SafeProperty $mdm "lastSyncDateTime") } else { $null }
$mamLastSeen = if ($hasMam) { Get-SafeProperty $latestMam "LastMamSync" } else { $null }
$signInLastSeen = if ($hasSignIn) { Convert-ToLocalDateTime (Get-SafeProperty $latestSignIn "createdDateTime") } else { $null }
$operatingSystem = if ($hasMdm -and -not [string]::IsNullOrWhiteSpace([string](Get-SafeProperty $mdm "operatingSystem"))) {
Get-SafeProperty $mdm "operatingSystem"
}
elseif (-not [string]::IsNullOrWhiteSpace([string](Get-SafeProperty $entra "operatingSystem"))) {
Get-SafeProperty $entra "operatingSystem"
}
elseif ($hasSignIn) {
Get-NestedProperty $latestSignIn @("deviceDetail", "operatingSystem")
}
elseif ($hasMam) {
Get-SafeProperty $latestMam "DeviceType"
}
else {
$null
}
$osVersion = if ($hasMdm -and -not [string]::IsNullOrWhiteSpace([string](Get-SafeProperty $mdm "osVersion"))) {
Get-SafeProperty $mdm "osVersion"
}
elseif (-not [string]::IsNullOrWhiteSpace([string](Get-SafeProperty $entra "operatingSystemVersion"))) {
Get-SafeProperty $entra "operatingSystemVersion"
}
elseif ($hasMam) {
Get-SafeProperty $latestMam "PlatformVersion"
}
else {
$null
}
$inventory.Add(
[pscustomobject][ordered]@{
UserDisplayName = Get-SafeProperty $owner "displayName"
UserPrincipalName = Get-SafeProperty $owner "userPrincipalName"
UserId = $ownerId
UserEnabled = Get-SafeProperty $owner "accountEnabled"
UserType = Get-SafeProperty $owner "userType"
DeviceName = if ($hasMdm -and -not [string]::IsNullOrWhiteSpace([string](Get-SafeProperty $mdm "deviceName"))) { Get-SafeProperty $mdm "deviceName" } else { $entraDeviceName }
Classification = Get-InventoryClassification -HasEntra $true -HasMdm $hasMdm -HasMam $hasMam -HasSignIn $hasSignIn
DataSources = Get-SourceLabel -HasEntra $true -HasMdm $hasMdm -HasMam $hasMam -HasSignIn $hasSignIn
OperatingSystem = $operatingSystem
OSVersion = $osVersion
Manufacturer = if ($hasMdm) { Get-SafeProperty $mdm "manufacturer" } else { Get-SafeProperty $entra "manufacturer" }
Model = if ($hasMdm) { Get-SafeProperty $mdm "model" } else { Get-SafeProperty $entra "model" }
SerialNumber = if ($hasMdm) { Get-SafeProperty $mdm "serialNumber" } else { $null }
Ownership = if ($hasMdm) { Get-SafeProperty $mdm "managedDeviceOwnerType" } else { Get-SafeProperty $entra "deviceOwnership" }
JoinTrustType = Get-SafeProperty $entra "trustType"
EntraProfileType = Get-SafeProperty $entra "profileType"
EntraAccountEnabled = Get-SafeProperty $entra "accountEnabled"
EntraIsManaged = Convert-ToBooleanOrNull (Get-SafeProperty $entra "isManaged")
EntraIsCompliant = Convert-ToBooleanOrNull (Get-SafeProperty $entra "isCompliant")
IntuneManaged = $hasMdm
IntuneComplianceState = if ($hasMdm) { Get-SafeProperty $mdm "complianceState" } else { $null }
IntuneManagementAgent = if ($hasMdm) { Get-SafeProperty $mdm "managementAgent" } else { $null }
IntuneEnrollmentType = if ($hasMdm) { Get-SafeProperty $mdm "deviceEnrollmentType" } else { $null }
MamProtected = $hasMam
MamApplicationCount = @($ownerMamRows | Select-Object -ExpandProperty ApplicationIdentifier -Unique).Count
MamApplications = (@($ownerMamRows | Select-Object -ExpandProperty ApplicationIdentifier -Unique) | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) -join "; "
MamFlaggedReasons = (@($ownerMamRows | Select-Object -ExpandProperty FlaggedReasons -Unique) | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) -join "; "
RegistrationDate = Convert-ToLocalDateTime (Get-SafeProperty $entra "registrationDateTime")
EntraApproximateLastSignIn = $entraLastSeen
IntuneLastCheckIn = $mdmLastSeen
MamLastSync = $mamLastSeen
LatestObservedSignIn = $signInLastSeen
BestAvailableLastSeen = Get-LatestDateTime @($entraLastSeen, $mdmLastSeen, $mamLastSeen, $signInLastSeen)
LatestSignInApplication = if ($hasSignIn) { Get-SafeProperty $latestSignIn "appDisplayName" } else { $null }
LatestSignInResource = if ($hasSignIn) { Get-SafeProperty $latestSignIn "resourceDisplayName" } else { $null }
LatestSignInClient = if ($hasSignIn) { Get-SafeProperty $latestSignIn "clientAppUsed" } else { $null }
LatestSignInBrowser = if ($hasSignIn) { Get-NestedProperty $latestSignIn @("deviceDetail", "browser") } else { $null }
LatestSignInIPAddress = if ($hasSignIn) { Get-SafeProperty $latestSignIn "ipAddress" } else { $null }
SignInManagedFlag = if ($hasSignIn) { Convert-ToBooleanOrNull (Get-NestedProperty $latestSignIn @("deviceDetail", "isManaged")) } else { $null }
SignInCompliantFlag = if ($hasSignIn) { Convert-ToBooleanOrNull (Get-NestedProperty $latestSignIn @("deviceDetail", "isCompliant")) } else { $null }
SignInTrustType = if ($hasSignIn) { Get-NestedProperty $latestSignIn @("deviceDetail", "trustType") } else { $null }
JailBroken = if ($hasMdm) { Get-SafeProperty $mdm "jailBroken" } else { $null }
ThreatState = if ($hasMdm) { Get-SafeProperty $mdm "partnerReportedThreatState" } else { $null }
WiFiMacAddress = if ($hasMdm) { Get-SafeProperty $mdm "wiFiMacAddress" } else { $null }
EthernetMacAddress = if ($hasMdm) { Get-SafeProperty $mdm "ethernetMacAddress" } else { $null }
EntraDeviceId = $entraDeviceId
EntraDirectoryObjectId = $entraDirectoryId
IntuneManagedDeviceId = if ($hasMdm) { Get-SafeProperty $mdm "id" } else { $null }
CorrelationConfidence = if ($hasMdm -or $hasSignIn) { "High" } elseif ($hasMam) { "Medium" } else { "High" }
CorrelationNotes = if ($hasMam -and $ownerMamRows.Count -eq 1 -and -not ([string](Get-SafeProperty $latestMam "DeviceName")).Equals($entraDeviceName, [System.StringComparison]::OrdinalIgnoreCase)) { "MAM associated by sole registration for this user; physical-device match is not guaranteed." } else { $null }
}
)
}
}
# Add MDM records that did not correlate to an Entra device object.
foreach ($mdm in $MdmDevices) {
$entraId = [string](Get-SafeProperty $mdm "azureADDeviceId")
if (-not [string]::IsNullOrWhiteSpace($entraId) -and $mdmByEntraDeviceId.ContainsKey($entraId.ToLowerInvariant())) {
$alreadyPresent = $inventory | Where-Object { $_.IntuneManagedDeviceId -eq (Get-SafeProperty $mdm "id") } | Select-Object -First 1
if ($null -ne $alreadyPresent) {
continue
}
}
$userId = [string](Get-SafeProperty $mdm "userId")
$user = if (-not [string]::IsNullOrWhiteSpace($userId) -and $UserLookup.ContainsKey($userId)) { $UserLookup[$userId] } else { $null }
$inventory.Add(
[pscustomobject][ordered]@{
UserDisplayName = if ($null -ne $user) { Get-SafeProperty $user "displayName" } else { Get-SafeProperty $mdm "userDisplayName" }
UserPrincipalName = if ($null -ne $user) { Get-SafeProperty $user "userPrincipalName" } else { Get-SafeProperty $mdm "userPrincipalName" }
UserId = $userId
UserEnabled = if ($null -ne $user) { Get-SafeProperty $user "accountEnabled" } else { $null }
UserType = if ($null -ne $user) { Get-SafeProperty $user "userType" } else { $null }
DeviceName = Get-SafeProperty $mdm "deviceName"
Classification = "Intune managed - no Entra match"
DataSources = "Intune MDM"
OperatingSystem = Get-SafeProperty $mdm "operatingSystem"
OSVersion = Get-SafeProperty $mdm "osVersion"
Manufacturer = Get-SafeProperty $mdm "manufacturer"
Model = Get-SafeProperty $mdm "model"
SerialNumber = Get-SafeProperty $mdm "serialNumber"
Ownership = Get-SafeProperty $mdm "managedDeviceOwnerType"
JoinTrustType = $null
EntraProfileType = $null
EntraAccountEnabled = $null
EntraIsManaged = $null
EntraIsCompliant = $null
IntuneManaged = $true
IntuneComplianceState = Get-SafeProperty $mdm "complianceState"
IntuneManagementAgent = Get-SafeProperty $mdm "managementAgent"
IntuneEnrollmentType = Get-SafeProperty $mdm "deviceEnrollmentType"
MamProtected = $false
MamApplicationCount = 0
MamApplications = $null
MamFlaggedReasons = $null
RegistrationDate = $null
EntraApproximateLastSignIn = $null
IntuneLastCheckIn = Convert-ToLocalDateTime (Get-SafeProperty $mdm "lastSyncDateTime")
MamLastSync = $null
LatestObservedSignIn = $null
BestAvailableLastSeen = Convert-ToLocalDateTime (Get-SafeProperty $mdm "lastSyncDateTime")
LatestSignInApplication = $null
LatestSignInResource = $null
LatestSignInClient = $null
LatestSignInBrowser = $null
LatestSignInIPAddress = $null
SignInManagedFlag = $null
SignInCompliantFlag = $null
SignInTrustType = $null
JailBroken = Get-SafeProperty $mdm "jailBroken"
ThreatState = Get-SafeProperty $mdm "partnerReportedThreatState"
WiFiMacAddress = Get-SafeProperty $mdm "wiFiMacAddress"
EthernetMacAddress = Get-SafeProperty $mdm "ethernetMacAddress"
EntraDeviceId = $entraId
EntraDirectoryObjectId = $null
IntuneManagedDeviceId = Get-SafeProperty $mdm "id"
CorrelationConfidence = "High"
CorrelationNotes = "Intune record did not match an Entra directory device object."
}
)
}
# Add unmatched MAM records, grouped into a best-effort MAM-only device view.
$unmatchedMam = @(
$MamRows | Where-Object {
$id = [string](Get-SafeProperty $_ "MamRegistrationId")
[string]::IsNullOrWhiteSpace($id) -or -not $matchedMamIds.Contains($id)
}
)
$mamGroups = $unmatchedMam | Group-Object {
"{0}|{1}|{2}|{3}" -f
(Get-SafeProperty $_ "UserId"),
(Get-SafeProperty $_ "DeviceName"),
(Get-SafeProperty $_ "DeviceType"),
(Get-SafeProperty $_ "PlatformVersion")
}
foreach ($group in $mamGroups) {
$rows = @($group.Group)
$latest = $rows | Sort-Object LastMamSync -Descending | Select-Object -First 1
$inventory.Add(
[pscustomobject][ordered]@{
UserDisplayName = Get-SafeProperty $latest "UserDisplayName"
UserPrincipalName = Get-SafeProperty $latest "UserPrincipalName"
UserId = Get-SafeProperty $latest "UserId"
UserEnabled = Get-SafeProperty $latest "UserEnabled"
UserType = Get-SafeProperty $latest "UserType"
DeviceName = Get-SafeProperty $latest "DeviceName"
Classification = "MAM only"
DataSources = "Intune MAM"
OperatingSystem = Get-SafeProperty $latest "DeviceType"
OSVersion = Get-SafeProperty $latest "PlatformVersion"
Manufacturer = $null
Model = $null
SerialNumber = $null
Ownership = "Personal or unknown"
JoinTrustType = $null
EntraProfileType = $null
EntraAccountEnabled = $null
EntraIsManaged = $false
EntraIsCompliant = $null
IntuneManaged = $false
IntuneComplianceState = $null
IntuneManagementAgent = "MAM"
IntuneEnrollmentType = "MAM without enrolment or unmatched"
MamProtected = $true
MamApplicationCount = @($rows | Select-Object -ExpandProperty ApplicationIdentifier -Unique).Count
MamApplications = (@($rows | Select-Object -ExpandProperty ApplicationIdentifier -Unique) | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) -join "; "
MamFlaggedReasons = (@($rows | Select-Object -ExpandProperty FlaggedReasons -Unique) | Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) }) -join "; "
RegistrationDate = ($rows | Sort-Object FirstRegistered | Select-Object -First 1).FirstRegistered
EntraApproximateLastSignIn = $null
IntuneLastCheckIn = $null
MamLastSync = Get-SafeProperty $latest "LastMamSync"
LatestObservedSignIn = $null
BestAvailableLastSeen = Get-SafeProperty $latest "LastMamSync"
LatestSignInApplication = $null
LatestSignInResource = $null
LatestSignInClient = $null
LatestSignInBrowser = $null
LatestSignInIPAddress = $null
SignInManagedFlag = $null
SignInCompliantFlag = $null
SignInTrustType = $null
JailBroken = $null
ThreatState = $null
WiFiMacAddress = $null
EthernetMacAddress = $null
EntraDeviceId = $null
EntraDirectoryObjectId = $null
IntuneManagedDeviceId = $null
CorrelationConfidence = "Medium"
CorrelationNotes = "MAM registrations describe protected app instances and may not uniquely identify a physical device."
}
)
}
# Add sign-in observations that have no matched Entra device ID.
$unmatchedSignIns = @(
$SignIns | Where-Object {
$id = [string](Get-SafeProperty $_ "id")
[string]::IsNullOrWhiteSpace($id) -or -not $matchedSignInIds.Contains($id)
}
)
$signInGroups = $unmatchedSignIns | Group-Object {
"{0}|{1}|{2}|{3}|{4}" -f
(Get-SafeProperty $_ "userId"),
(Get-NestedProperty $_ @("deviceDetail", "deviceId")),
(Get-NestedProperty $_ @("deviceDetail", "operatingSystem")),
(Get-NestedProperty $_ @("deviceDetail", "browser")),
(Get-SafeProperty $_ "clientAppUsed")
}
foreach ($group in $signInGroups) {
$rows = @($group.Group)
$latest = $rows |
Sort-Object { Convert-ToLocalDateTime (Get-SafeProperty $_ "createdDateTime") } -Descending |
Select-Object -First 1
$deviceId = [string](Get-NestedProperty $latest @("deviceDetail", "deviceId"))
$userId = [string](Get-SafeProperty $latest "userId")
$user = if (-not [string]::IsNullOrWhiteSpace($userId) -and $UserLookup.ContainsKey($userId)) { $UserLookup[$userId] } else { $null }
$inventory.Add(
[pscustomobject][ordered]@{
UserDisplayName = if ($null -ne $user) { Get-SafeProperty $user "displayName" } else { Get-SafeProperty $latest "userDisplayName" }
UserPrincipalName = if ($null -ne $user) { Get-SafeProperty $user "userPrincipalName" } else { Get-SafeProperty $latest "userPrincipalName" }
UserId = $userId
UserEnabled = if ($null -ne $user) { Get-SafeProperty $user "accountEnabled" } else { $null }
UserType = if ($null -ne $user) { Get-SafeProperty $user "userType" } else { $null }
DeviceName = if ([string]::IsNullOrWhiteSpace($deviceId)) { "Unknown device" } else { $deviceId }
Classification = "Sign-in only"
DataSources = "Sign-in"
OperatingSystem = Get-NestedProperty $latest @("deviceDetail", "operatingSystem")
OSVersion = $null
Manufacturer = $null
Model = $null
SerialNumber = $null
Ownership = "Unknown"
JoinTrustType = Get-NestedProperty $latest @("deviceDetail", "trustType")
EntraProfileType = $null
EntraAccountEnabled = $null
EntraIsManaged = Convert-ToBooleanOrNull (Get-NestedProperty $latest @("deviceDetail", "isManaged"))
EntraIsCompliant = Convert-ToBooleanOrNull (Get-NestedProperty $latest @("deviceDetail", "isCompliant"))
IntuneManaged = $false
IntuneComplianceState = $null
IntuneManagementAgent = $null
IntuneEnrollmentType = $null
MamProtected = $false
MamApplicationCount = 0
MamApplications = $null
MamFlaggedReasons = $null
RegistrationDate = $null
EntraApproximateLastSignIn = $null
IntuneLastCheckIn = $null
MamLastSync = $null
LatestObservedSignIn = Convert-ToLocalDateTime (Get-SafeProperty $latest "createdDateTime")
BestAvailableLastSeen = Convert-ToLocalDateTime (Get-SafeProperty $latest "createdDateTime")
LatestSignInApplication = Get-SafeProperty $latest "appDisplayName"
LatestSignInResource = Get-SafeProperty $latest "resourceDisplayName"
LatestSignInClient = Get-SafeProperty $latest "clientAppUsed"
LatestSignInBrowser = Get-NestedProperty $latest @("deviceDetail", "browser")
LatestSignInIPAddress = Get-SafeProperty $latest "ipAddress"
SignInManagedFlag = Convert-ToBooleanOrNull (Get-NestedProperty $latest @("deviceDetail", "isManaged"))
SignInCompliantFlag = Convert-ToBooleanOrNull (Get-NestedProperty $latest @("deviceDetail", "isCompliant"))
SignInTrustType = Get-NestedProperty $latest @("deviceDetail", "trustType")
JailBroken = $null
ThreatState = $null
WiFiMacAddress = $null
EthernetMacAddress = $null
EntraDeviceId = $deviceId
EntraDirectoryObjectId = $null
IntuneManagedDeviceId = $null
CorrelationConfidence = if ([string]::IsNullOrWhiteSpace($deviceId)) { "Low" } else { "Medium" }
CorrelationNotes = if ([string]::IsNullOrWhiteSpace($deviceId)) { "No stable device ID was supplied in the sign-in record; rows are grouped by user, OS, browser and client." } else { "Sign-in supplied a device ID that did not match the retrieved Entra device inventory." }
}
)
}
return @(
$inventory |
Sort-Object `
UserPrincipalName,
@{ Expression = "BestAvailableLastSeen"; Descending = $true },
DeviceName
)
}
function New-SummaryRows {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object[]]$Inventory,
[Parameter(Mandatory)]
[int]$EntraDeviceCount,
[Parameter(Mandatory)]
[int]$MdmCount,
[Parameter(Mandatory)]
[int]$MamCount,
[Parameter(Mandatory)]
[int]$SignInCount,
[Parameter(Mandatory)]
[int]$LookbackDays
)
$now = Get-Date
$stale30 = @($Inventory | Where-Object { $null -eq $_.BestAvailableLastSeen -or $_.BestAvailableLastSeen -lt $now.AddDays(-30) }).Count
$stale90 = @($Inventory | Where-Object { $null -eq $_.BestAvailableLastSeen -or $_.BestAvailableLastSeen -lt $now.AddDays(-90) }).Count
return @(
[pscustomobject]@{ Metric = "Report generated"; Value = $now }
[pscustomobject]@{ Metric = "Sign-in lookback days"; Value = $LookbackDays }
[pscustomobject]@{ Metric = "Unified inventory rows"; Value = $Inventory.Count }
[pscustomobject]@{ Metric = "Unique users in inventory"; Value = @($Inventory | Where-Object { $_.UserPrincipalName -and $_.UserPrincipalName -ne "(Unassigned)" } | Select-Object -ExpandProperty UserPrincipalName -Unique).Count }
[pscustomobject]@{ Metric = "Entra registered device objects"; Value = $EntraDeviceCount }
[pscustomobject]@{ Metric = "Intune MDM records"; Value = $MdmCount }
[pscustomobject]@{ Metric = "Intune MAM app registrations"; Value = $MamCount }
[pscustomobject]@{ Metric = "Sign-in records analysed"; Value = $SignInCount }
[pscustomobject]@{ Metric = "Intune managed inventory rows"; Value = @($Inventory | Where-Object IntuneManaged -eq $true).Count }
[pscustomobject]@{ Metric = "Entra registered, unmanaged rows"; Value = @($Inventory | Where-Object Classification -eq "Entra registered - unmanaged").Count }
[pscustomobject]@{ Metric = "MAM-only rows"; Value = @($Inventory | Where-Object Classification -eq "MAM only").Count }
[pscustomobject]@{ Metric = "Sign-in-only rows"; Value = @($Inventory | Where-Object Classification -eq "Sign-in only").Count }
[pscustomobject]@{ Metric = "Rows not seen in 30 days or never"; Value = $stale30 }
[pscustomobject]@{ Metric = "Rows not seen in 90 days or never"; Value = $stale90 }
[pscustomobject]@{ Metric = "Collection issues"; Value = $script:CollectionIssues.Count }
)
}
function Export-ExcelReport {
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Path,
[Parameter(Mandatory)]
[object[]]$Inventory,
[Parameter(Mandatory)]
[object[]]$MamRows,
[Parameter(Mandatory)]
[object[]]$SignInOnlyRows,
[Parameter(Mandatory)]
[object[]]$SummaryRows,
[Parameter()]
[AllowEmptyCollection()]
[object[]]$Issues = @()
)
if (Test-Path -LiteralPath $Path) {
Remove-Item -LiteralPath $Path -Force
}
$common = @{
Path = $Path
AutoSize = $true
AutoFilter = $true
FreezeTopRow = $true
BoldTopRow = $true
TableStyle = "Medium2"
ClearSheet = $true
}
$Inventory | Export-Excel @common -WorksheetName "Inventory" -TableName "UnifiedInventory"
$MamRows | Export-Excel @common -WorksheetName "MAM Registrations" -TableName "MamRegistrations"
$SignInOnlyRows | Export-Excel @common -WorksheetName "Sign-In Only" -TableName "SignInOnly"
$SummaryRows | Export-Excel @common -WorksheetName "Summary" -TableName "SummaryMetrics"
$issuesForExcel = @($Issues)
if ($issuesForExcel.Count -eq 0) {
$issuesForExcel = @(
[pscustomobject][ordered]@{
Timestamp = Get-Date
Severity = "Information"
Source = "Report"
ObjectId = $null
Message = "No collection issues were recorded."
}
)
}
$issuesForExcel | Export-Excel @common -WorksheetName "Collection Issues" -TableName "CollectionIssues"
$package = Open-ExcelPackage -Path $Path
try {
$inventorySheet = $package.Workbook.Worksheets["Inventory"]
if ($null -ne $inventorySheet -and $inventorySheet.Dimension) {
$inventorySheet.View.FreezePanes(2, 1)
$inventorySheet.Column(1).Width = 24
$inventorySheet.Column(2).Width = 34
$inventorySheet.Column(6).Width = 34
$inventorySheet.Column(7).Width = 30
$inventorySheet.Column(8).Width = 24
$inventorySheet.Column(31).Width = 24
$inventorySheet.Column(32).Width = 24
$inventorySheet.Column(33).Width = 24
$inventorySheet.Column(34).Width = 24
$inventorySheet.Column(35).Width = 24
$inventorySheet.Column(55).Width = 65
# Classification colours: managed, Entra-only, MAM-only and sign-in-only.
Add-ConditionalFormatting `
-WorkSheet $inventorySheet `
-Address ("F2:F{0}" -f $inventorySheet.Dimension.End.Row) `
-RuleType ContainsText `
-ConditionValue "Intune managed" `
-BackgroundColor "C6EFCE" `
-ForegroundColor "006100"
Add-ConditionalFormatting `
-WorkSheet $inventorySheet `
-Address ("F2:F{0}" -f $inventorySheet.Dimension.End.Row) `
-RuleType ContainsText `
-ConditionValue "Entra registered - unmanaged" `
-BackgroundColor "FFEB9C" `
-ForegroundColor "9C6500"
Add-ConditionalFormatting `
-WorkSheet $inventorySheet `
-Address ("F2:F{0}" -f $inventorySheet.Dimension.End.Row) `
-RuleType ContainsText `
-ConditionValue "MAM only" `
-BackgroundColor "DDEBF7" `
-ForegroundColor "1F4E78"
Add-ConditionalFormatting `
-WorkSheet $inventorySheet `
-Address ("F2:F{0}" -f $inventorySheet.Dimension.End.Row) `
-RuleType ContainsText `
-ConditionValue "Sign-in only" `
-BackgroundColor "F4CCCC" `
-ForegroundColor "9C0006"
}
Close-ExcelPackage -ExcelPackage $package
}
catch {
if ($null -ne $package) {
Close-ExcelPackage -ExcelPackage $package -NoSave
}
throw
}
}
try {
Write-Section "Preparing dependencies"
[void](Ensure-Module -Name "Microsoft.Graph.Authentication")
$excelAvailable = $false
if (-not $SkipExcel) {
$excelAvailable = Ensure-Module -Name "ImportExcel" -Optional
}
New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
Write-Section "Connecting to Microsoft Graph"
$scopes = @(
"Device.Read.All"
"Directory.Read.All"
"User.Read.All"
"DeviceManagementManagedDevices.Read.All"
"DeviceManagementApps.Read.All"
"AuditLog.Read.All"
)
$connectParameters = @{
Scopes = $scopes
ContextScope = "Process"
NoWelcome = $true
}
if (-not [string]::IsNullOrWhiteSpace($TenantId)) {
$connectParameters["TenantId"] = $TenantId
}
Connect-MgGraph @connectParameters
$context = Get-MgContext
if ($null -eq $context) {
throw "Microsoft Graph sign-in did not return an authentication context."
}
Write-Host ("Signed in as: {0}" -f $context.Account) -ForegroundColor Green
Write-Host ("Tenant ID: {0}" -f $context.TenantId)
$users = @(Get-EntraUsers)
$userLookup = @{}
foreach ($user in $users) {
$id = [string](Get-SafeProperty $user "id")
if (-not [string]::IsNullOrWhiteSpace($id)) {
$userLookup[$id] = $user
}
}
$entraDevices = @(Get-EntraDevices)
$ownerLookup = Get-EntraDeviceOwnerLookup -Devices $entraDevices
$mdmDevices = @(Get-IntuneManagedDevices)
$mamRegistrations = @(Get-IntuneMamRegistrations)
$signIns = @(Get-EntraSignIns -LookbackDays $SignInLookbackDays -IncludeFailures:$IncludeFailedSignIns)
Write-Section "Normalising MAM registrations"
$mamRows = @(
foreach ($registration in $mamRegistrations) {
try {
Convert-MamRegistration -Registration $registration -UserLookup $userLookup
}
catch {
$registrationId = [string](Get-SafeProperty $registration "id")
Add-CollectionIssue `
-Source "MAM normalisation" `
-ObjectId $registrationId `
-Message $_.Exception.Message
}
}
)
$inventory = @(
New-UnifiedInventory `
-EntraDevices $entraDevices `
-OwnerLookup $ownerLookup `
-MdmDevices $mdmDevices `
-MamRows $mamRows `
-SignIns $signIns `
-UserLookup $userLookup
)
$signInOnlyRows = @($inventory | Where-Object Classification -eq "Sign-in only")
$summaryRows = @(
New-SummaryRows `
-Inventory $inventory `
-EntraDeviceCount $entraDevices.Count `
-MdmCount $mdmDevices.Count `
-MamCount $mamRegistrations.Count `
-SignInCount $signIns.Count `
-LookbackDays $SignInLookbackDays
)
Write-Section "Exporting reports"
$inventoryCsv = Join-Path $OutputDirectory "UnifiedDeviceInventory.csv"
$mamCsv = Join-Path $OutputDirectory "MamAppRegistrations.csv"
$signInCsv = Join-Path $OutputDirectory "SignInOnlyObservations.csv"
$issuesCsv = Join-Path $OutputDirectory "CollectionIssues.csv"
$excelPath = Join-Path $OutputDirectory "UnifiedDeviceInventory.xlsx"
$inventory | Export-Csv -Path $inventoryCsv -NoTypeInformation -Encoding UTF8
$mamRows | Export-Csv -Path $mamCsv -NoTypeInformation -Encoding UTF8
$signInOnlyRows | Export-Csv -Path $signInCsv -NoTypeInformation -Encoding UTF8
$issuesForExport = @($script:CollectionIssues)
if ($issuesForExport.Count -eq 0) {
$issuesForExport = @(
[pscustomobject][ordered]@{
Timestamp = Get-Date
Severity = "Information"
Source = "Report"
ObjectId = $null
Message = "No collection issues were recorded."
}
)
}
$issuesForExport | Export-Csv -Path $issuesCsv -NoTypeInformation -Encoding UTF8
if ($excelAvailable) {
try {
Export-ExcelReport `
-Path $excelPath `
-Inventory $inventory `
-MamRows $mamRows `
-SignInOnlyRows $signInOnlyRows `
-SummaryRows $summaryRows `
-Issues @($script:CollectionIssues)
Write-Host ("Excel report: {0}" -f $excelPath) -ForegroundColor Green
}
catch {
Add-CollectionIssue -Source "Excel export" -Message $_.Exception.Message
Write-Warning ("Excel export failed, but CSV files were created: {0}" -f $_.Exception.Message)
@($script:CollectionIssues) | Export-Csv -Path $issuesCsv -NoTypeInformation -Encoding UTF8
$summaryRows = @(
New-SummaryRows `
-Inventory $inventory `
-EntraDeviceCount $entraDevices.Count `
-MdmCount $mdmDevices.Count `
-MamCount $mamRegistrations.Count `
-SignInCount $signIns.Count `
-LookbackDays $SignInLookbackDays
)
}
}
Write-Section "Summary"
$summaryRows | Format-Table -AutoSize
Write-Host ""
Write-Host ("Output directory: {0}" -f $OutputDirectory) -ForegroundColor Yellow
Write-Host ("Inventory CSV: {0}" -f $inventoryCsv)
Write-Host ("MAM detail CSV: {0}" -f $mamCsv)
Write-Host ("Sign-in-only CSV: {0}" -f $signInCsv)
Write-Host ("Issues CSV: {0}" -f $issuesCsv)
if ($script:CollectionIssues.Count -gt 0) {
Write-Warning (
"The report completed with {0} collection issue(s). Review CollectionIssues.csv." -f
$script:CollectionIssues.Count
)
}
else {
Write-Host "Report completed without recorded collection issues." -ForegroundColor Green
}
}
catch {
Write-Host ""
Write-Host "Report failed:" -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Red
if (-not (Test-Path -LiteralPath $OutputDirectory)) {
New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
}
Add-CollectionIssue -Source "Fatal" -Severity "Error" -Message $_.Exception.Message
@($script:CollectionIssues) |
Export-Csv `
-Path (Join-Path $OutputDirectory "CollectionIssues.csv") `
-NoTypeInformation `
-Encoding UTF8
exit 1
}
finally {
if (Get-Command Get-MgContext -ErrorAction SilentlyContinue) {
if (Get-MgContext -ErrorAction SilentlyContinue) {
Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
}
}
}