From b9725955f227206068e99d553ec032aed09ad11c Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 25 Oct 2026 22:20:05 +0100 Subject: [PATCH 01/24] refactor(core): move Intune anomaly script state into Initialize-RKIntuneCoreState Co-Authored-By: Claude Opus 5.5 --- module/Core/Initialize-RKIntuneCoreState.ps1 | 61 ++++++++++++++++++++ module/Core/README.md | 12 ++++ module/Private/IntuneAnomalies.ps1 | 44 -------------- module/Private/IntuneEnrollmentFlows.ps1 | 9 --- module/RKSolutions.psm1 | 7 +++ 5 files changed, 80 insertions(+), 53 deletions(-) create mode 100644 module/Core/Initialize-RKIntuneCoreState.ps1 create mode 100644 module/Core/README.md diff --git a/module/Core/Initialize-RKIntuneCoreState.ps1 b/module/Core/Initialize-RKIntuneCoreState.ps1 new file mode 100644 index 0000000..75391ff --- /dev/null +++ b/module/Core/Initialize-RKIntuneCoreState.ps1 @@ -0,0 +1,61 @@ +function Initialize-RKIntuneCoreState { + <# + .SYNOPSIS + Creates the script-scope state the Intune anomaly core reads. A collection calls it first, so each + collection starts with empty caches; RKSolutions.psm1 calls it once on load for Enrollment Flows. + #> + [CmdletBinding()] + param() + + $script:BitLockerSettingPrefix = 'device_vendor_msft_bitlocker_' + $script:LapsSettingPrefix = 'device_vendor_msft_laps_policies_' + $script:LapsBackupDirectorySetting = 'device_vendor_msft_laps_policies_backupdirectory' + $script:AllFilters = @{} + $script:AllGroups = @{} + $script:ODataToFriendly = @{ + '#microsoft.graph.allDevicesAssignmentTarget' = 'All Devices' + '#microsoft.graph.allLicensedUsersAssignmentTarget' = 'All Users' + '#microsoft.graph.groupAssignmentTarget' = 'Group (Include)' + '#microsoft.graph.exclusionGroupAssignmentTarget' = 'Group (Exclude)' + } + # Admin-center names for compliance rules, keyed by the settingName suffix Graph returns. + # Sources: learn.microsoft.com/intune/device-security/compliance/ref-windows-settings + # learn.microsoft.com/intune/device-security/compliance/monitor-policy (built-in policy) + # Names must not contain ', ' - the Noncompliant tab splits reasons on it. + $script:ComplianceSettingFriendlyNames = @{ + 'RequireRemainContact' = 'Is active (no compliance check-in within the compliance status validity period)' + 'RequireDeviceCompliancePolicyAssigned' = 'Has a compliance policy assigned' + 'RequireUserExistence' = 'Enrolled user exists (user deleted or disabled or without an Intune license)' + 'BitLockerEnabled' = 'Require BitLocker' + 'SecureBootEnabled' = 'Require Secure Boot to be enabled on the device' + 'CodeIntegrityEnabled' = 'Require code integrity' + 'EarlyLaunchAntiMalwareDriverEnabled' = 'Early launch antimalware driver enabled' + 'RequireHealthyDeviceReport' = 'Reported healthy by Device Health Attestation' + 'OsMinimumVersion' = 'Minimum OS version' + 'OsMaximumVersion' = 'Maximum OS version' + 'MobileOsMinimumVersion' = 'Minimum OS required for mobile devices' + 'MobileOsMaximumVersion' = 'Maximum OS required for mobile devices' + 'ValidOperatingSystemBuildRanges' = 'Valid operating system builds' + 'ConfigurationManagerComplianceRequired' = 'Require device compliance from Configuration Manager' + 'PasswordRequired' = 'Require a password to unlock mobile devices' + 'PasswordBlockSimple' = 'Simple passwords' + 'PasswordRequiredType' = 'Password type' + 'PasswordMinimumCharacterSetCount' = 'Password complexity' + 'PasswordMinimumLength' = 'Minimum password length' + 'PasswordMinutesOfInactivityBeforeLock' = 'Maximum minutes of inactivity before password is required' + 'PasswordExpirationDays' = 'Password expiration (days)' + 'PasswordPreviousPasswordBlockCount' = 'Number of previous passwords to prevent reuse' + 'PasswordRequiredToUnlockFromIdle' = 'Require password when device returns from idle state' + 'StorageRequireEncryption' = 'Encryption of data storage on a device' + 'ActiveFirewallRequired' = 'Firewall (turned off or allowing all inbound traffic)' + 'TpmRequired' = 'Trusted Platform Module (TPM)' + 'AntivirusRequired' = 'Antivirus (turned off or out of date in Windows Security)' + 'AntiSpywareRequired' = 'Antispyware (turned off or out of date in Windows Security)' + 'DefenderEnabled' = 'Microsoft Defender Antimalware (service turned off)' + 'DefenderVersion' = 'Microsoft Defender Antimalware minimum version' + 'SignatureOutOfDate' = 'Microsoft Defender Antimalware security intelligence up-to-date' + 'RtpEnabled' = 'Real-time protection (turned off)' + 'DeviceThreatProtectionEnabled' = 'Require the device to be at or under the machine risk score' + 'DeviceThreatProtectionRequiredSecurityLevel' = 'Require the device to be at or under the machine risk score' + } +} diff --git a/module/Core/README.md b/module/Core/README.md new file mode 100644 index 0000000..9eb418e --- /dev/null +++ b/module/Core/README.md @@ -0,0 +1,12 @@ +# Core + +The files in this folder are copied into the RKSolutions Maester pack (royklo/maester-pack-rksolutions) by +tools/Sync-Core.ps1. They follow the Maester pack rules: + +- only function definitions at the top level; script-scope values are created by Initialize-RKIntuneCoreState; +- Microsoft Graph is only read, and only through Invoke-RKCoreGraphRequest and Invoke-RKCoreGraphBatch; +- no other host, no Invoke-WebRequest or Invoke-RestMethod, no Invoke-Expression, no scriptblock variables + called with &, no Add-Type, no computed command names; +- no variables from the caller's scope. + +Run the pack repo's tools/Test-Pack.ps1 -Scan after a change; it must print "SCAN OK". diff --git a/module/Private/IntuneAnomalies.ps1 b/module/Private/IntuneAnomalies.ps1 index a14d72e..82269d9 100644 --- a/module/Private/IntuneAnomalies.ps1 +++ b/module/Private/IntuneAnomalies.ps1 @@ -1295,47 +1295,6 @@ function New-IntuneAnomaliesHTMLReport { } -# Admin-center names for compliance rules, keyed by the settingName suffix Graph returns. -# Sources: learn.microsoft.com/intune/device-security/compliance/ref-windows-settings -# learn.microsoft.com/intune/device-security/compliance/monitor-policy (built-in policy) -# Names must not contain ', ' - the Noncompliant tab splits reasons on it. -$script:ComplianceSettingFriendlyNames = @{ - 'RequireRemainContact' = 'Is active (no compliance check-in within the compliance status validity period)' - 'RequireDeviceCompliancePolicyAssigned' = 'Has a compliance policy assigned' - 'RequireUserExistence' = 'Enrolled user exists (user deleted or disabled or without an Intune license)' - 'BitLockerEnabled' = 'Require BitLocker' - 'SecureBootEnabled' = 'Require Secure Boot to be enabled on the device' - 'CodeIntegrityEnabled' = 'Require code integrity' - 'EarlyLaunchAntiMalwareDriverEnabled' = 'Early launch antimalware driver enabled' - 'RequireHealthyDeviceReport' = 'Reported healthy by Device Health Attestation' - 'OsMinimumVersion' = 'Minimum OS version' - 'OsMaximumVersion' = 'Maximum OS version' - 'MobileOsMinimumVersion' = 'Minimum OS required for mobile devices' - 'MobileOsMaximumVersion' = 'Maximum OS required for mobile devices' - 'ValidOperatingSystemBuildRanges' = 'Valid operating system builds' - 'ConfigurationManagerComplianceRequired' = 'Require device compliance from Configuration Manager' - 'PasswordRequired' = 'Require a password to unlock mobile devices' - 'PasswordBlockSimple' = 'Simple passwords' - 'PasswordRequiredType' = 'Password type' - 'PasswordMinimumCharacterSetCount' = 'Password complexity' - 'PasswordMinimumLength' = 'Minimum password length' - 'PasswordMinutesOfInactivityBeforeLock' = 'Maximum minutes of inactivity before password is required' - 'PasswordExpirationDays' = 'Password expiration (days)' - 'PasswordPreviousPasswordBlockCount' = 'Number of previous passwords to prevent reuse' - 'PasswordRequiredToUnlockFromIdle' = 'Require password when device returns from idle state' - 'StorageRequireEncryption' = 'Encryption of data storage on a device' - 'ActiveFirewallRequired' = 'Firewall (turned off or allowing all inbound traffic)' - 'TpmRequired' = 'Trusted Platform Module (TPM)' - 'AntivirusRequired' = 'Antivirus (turned off or out of date in Windows Security)' - 'AntiSpywareRequired' = 'Antispyware (turned off or out of date in Windows Security)' - 'DefenderEnabled' = 'Microsoft Defender Antimalware (service turned off)' - 'DefenderVersion' = 'Microsoft Defender Antimalware minimum version' - 'SignatureOutOfDate' = 'Microsoft Defender Antimalware security intelligence up-to-date' - 'RtpEnabled' = 'Real-time protection (turned off)' - 'DeviceThreatProtectionEnabled' = 'Require the device to be at or under the machine risk score' - 'DeviceThreatProtectionRequiredSecurityLevel' = 'Require the device to be at or under the machine risk score' -} - function ConvertTo-ComplianceSettingFriendlyName { param([string] $SettingName) if ($SettingName -match '^(Windows10CompliancePolicy|DefaultDeviceCompliancePolicy)\.(.+)$' -and $script:ComplianceSettingFriendlyNames.ContainsKey($Matches[2])) { @@ -1886,9 +1845,6 @@ function Get-AutopilotProfilesInformation { # IntuneEnrollmentFlows.ps1 - load order is enforced in RKSolutions.psm1. # ---------------------------------------------------------------------------- -$script:BitLockerSettingPrefix = 'device_vendor_msft_bitlocker_' -$script:LapsSettingPrefix = 'device_vendor_msft_laps_policies_' -$script:LapsBackupDirectorySetting = 'device_vendor_msft_laps_policies_backupdirectory' function Get-BitLockerLapsAssignmentContext { <# diff --git a/module/Private/IntuneEnrollmentFlows.ps1 b/module/Private/IntuneEnrollmentFlows.ps1 index 0db5aeb..300d023 100644 --- a/module/Private/IntuneEnrollmentFlows.ps1 +++ b/module/Private/IntuneEnrollmentFlows.ps1 @@ -1,20 +1,11 @@ # Intune Enrollment Flows - Private helpers $ErrorActionPreference = 'Stop' -$script:AllFilters = @{} -$script:AllGroups = @{} $script:GroupMemberTypeCache = $null # Shared helper: convert boolean/null to Yes/No/dash $script:ToYesNo = { param($v) if ($null -eq $v) { "-" } elseif ($v -eq $true) { "Yes" } elseif ($v -eq $false) { "No" } else { $v.ToString() } } -# Shared lookup tables for OData assignment type <-> friendly name -$script:ODataToFriendly = @{ - '#microsoft.graph.allDevicesAssignmentTarget' = 'All Devices' - '#microsoft.graph.allLicensedUsersAssignmentTarget' = 'All Users' - '#microsoft.graph.groupAssignmentTarget' = 'Group (Include)' - '#microsoft.graph.exclusionGroupAssignmentTarget' = 'Group (Exclude)' -} # Single source of truth for Graph device $select (Section 8.1) $script:ManagedDeviceSelectProperties = @( diff --git a/module/RKSolutions.psm1 b/module/RKSolutions.psm1 index 545d9b0..3dffd9a 100644 --- a/module/RKSolutions.psm1 +++ b/module/RKSolutions.psm1 @@ -49,6 +49,13 @@ if (Test-Path $privatePath) { } } +# Intune anomaly core: pack-proof files shared with the RKSolutions Maester pack (see module/Core/README.md). +$corePath = Join-Path $moduleRoot 'Core' +if (Test-Path $corePath) { + Get-ChildItem -Path $corePath -Filter '*.ps1' -File | Sort-Object Name | ForEach-Object { . $_.FullName } + Initialize-RKIntuneCoreState +} + # Load Public scripts (exported cmdlets) $publicPath = Join-Path $moduleRoot 'Public' if (Test-Path $publicPath) { From 613702e23e94418e4fb389de2ff14a6d418e3b60 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 25 Oct 2026 22:20:24 +0100 Subject: [PATCH 02/24] feat(core): Graph path, status and batch-body helpers, and the module's core adapters Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build-and-test.yml | 2 +- Tests/IntuneAnomalyCore.Tests.ps1 | 73 +++++++++++++++++++++++++++ module/Core/RKCoreGraph.ps1 | 50 ++++++++++++++++++ module/Private/RKCoreGraphAdapter.ps1 | 51 +++++++++++++++++++ module/RKSolutions.psm1 | 1 + 5 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 Tests/IntuneAnomalyCore.Tests.ps1 create mode 100644 module/Core/RKCoreGraph.ps1 create mode 100644 module/Private/RKCoreGraphAdapter.ps1 diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index b7e7948..68fdc96 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -21,7 +21,7 @@ jobs: - name: Run Consistency.Tests.ps1 run: | - $result = Invoke-Pester ./Tests/Consistency.Tests.ps1 -PassThru -Output Detailed + $result = Invoke-Pester ./Tests -PassThru -Output Detailed if ($result.FailedCount -gt 0) { exit 1 } shell: pwsh diff --git a/Tests/IntuneAnomalyCore.Tests.ps1 b/Tests/IntuneAnomalyCore.Tests.ps1 new file mode 100644 index 0000000..0fc91f5 --- /dev/null +++ b/Tests/IntuneAnomalyCore.Tests.ps1 @@ -0,0 +1,73 @@ +# IntuneAnomalyCore.Tests.ps1 - unit tests for module/Core and the module's core adapters. No Graph connection. + +BeforeAll { + Remove-Module -Name 'RKSolutions' -ErrorAction SilentlyContinue + Import-Module (Join-Path $PSScriptRoot '..' 'module' 'RKSolutions.psd1') -Force +} + +Describe 'ConvertTo-RKCoreGraphPath' { + It 'Splits ' -ForEach @( + @{ Uri = 'beta/deviceManagement/managedDevices'; Version = 'beta'; Path = 'deviceManagement/managedDevices' } + @{ Uri = '/v1.0/groups/1/transitiveMembers'; Version = 'v1.0'; Path = 'groups/1/transitiveMembers' } + @{ Uri = 'https://graph.microsoft.com/v1.0/devices?$select=id'; Version = 'v1.0'; Path = 'devices?$select=id' } + ) { + $r = InModuleScope RKSolutions -Parameters @{ Uri = $Uri } { param($Uri) ConvertTo-RKCoreGraphPath -Uri $Uri } + $r.Version | Should -Be $Version + $r.Path | Should -Be $Path + } + + It 'Refuses a URI without a version' { + { InModuleScope RKSolutions { ConvertTo-RKCoreGraphPath -Uri 'deviceManagement/managedDevices' } } | Should -Throw '*v1.0/ or beta/*' + } +} + +Describe 'Get-RKCoreBatchBody' { + It 'Returns the body for 200' { + InModuleScope RKSolutions { + $w = [System.Collections.Generic.List[object]]::new() + (Get-RKCoreBatchBody -Response ([pscustomobject]@{ Id = 'a'; Status = 200; Body = @{ value = 1 } }) -What 'x' -Area 'BitLocker' -Warnings $w).value | Should -Be 1 + $w.Count | Should -Be 0 + } + } + It 'Returns $null and warns for 404' { + InModuleScope RKSolutions { + $w = [System.Collections.Generic.List[object]]::new() + Get-RKCoreBatchBody -Response ([pscustomobject]@{ Id = 'a'; Status = 404; Body = $null }) -What 'group g1' -Area 'Laps' -Warnings $w | Should -BeNullOrEmpty + $w[0].Area | Should -Be 'Laps' + $w[0].Message | Should -BeLike '*group g1*' + } + } + It 'Throws for any other status' { + { InModuleScope RKSolutions { + $w = [System.Collections.Generic.List[object]]::new() + Get-RKCoreBatchBody -Response ([pscustomobject]@{ Id = 'a'; Status = 403; Body = $null }) -What 'policy p1' -Area 'BitLocker' -Warnings $w + } } | Should -Throw '*policy p1*403*' + } +} + +Describe 'Module adapters' { + It 'Invoke-RKCoreGraphRequest -All follows nextLink' { + InModuleScope RKSolutions { + Mock Invoke-MgGraphRequest -ParameterFilter { $Uri -like '*skip=2' } { [pscustomobject]@{ value = @(3) } } + Mock Invoke-MgGraphRequest -ParameterFilter { $Uri -notlike '*skip=2' } { [pscustomobject]@{ value = @(1, 2); '@odata.nextLink' = 'https://graph.microsoft.com/beta/x?skip=2' } } + @(Invoke-RKCoreGraphRequest -Uri 'beta/x' -All) | Should -Be @(1, 2, 3) + } + } + It 'Invoke-RKCoreGraphRequest without -All returns the raw body' { + InModuleScope RKSolutions { + Mock Invoke-MgGraphRequest { [pscustomobject]@{ value = @(1); '@odata.nextLink' = 'n' } } + (Invoke-RKCoreGraphRequest -Uri 'beta/x').'@odata.nextLink' | Should -Be 'n' + } + } + It 'Invoke-RKCoreGraphBatch groups requests by version' { + InModuleScope RKSolutions { + Mock Invoke-RKGraphBatch { foreach ($r in $Requests) { [pscustomobject]@{ Id = $r.Id; Status = 200; Body = "$GraphVersion$($r.Url)" } } } + $out = @(Invoke-RKCoreGraphBatch -Requests @( + [pscustomobject]@{ Id = 'a'; Url = 'beta/x' } + [pscustomobject]@{ Id = 'b'; Url = 'v1.0/y' } + )) + ($out | Where-Object Id -EQ 'a').Body | Should -Be 'beta/x' + ($out | Where-Object Id -EQ 'b').Body | Should -Be 'v1.0/y' + } + } +} diff --git a/module/Core/RKCoreGraph.ps1 b/module/Core/RKCoreGraph.ps1 new file mode 100644 index 0000000..4f98a85 --- /dev/null +++ b/module/Core/RKCoreGraph.ps1 @@ -0,0 +1,50 @@ +function ConvertTo-RKCoreGraphPath { + <# + .SYNOPSIS + Splits a Graph URL ('beta/x', '/v1.0/x' or 'https://graph.microsoft.com/beta/x') into Version and Path. + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param([Parameter(Mandatory)] [string] $Uri) + + $u = ($Uri -replace '^https?://graph\.microsoft\.com', '') -replace '^/+', '' + if ($u -notmatch '^(v1\.0|beta)/(.+)$') { throw "The Graph URI '$Uri' must start with v1.0/ or beta/." } + [pscustomobject]@{ Version = $Matches[1]; Path = $Matches[2] } +} + +function Get-RKCoreGraphErrorStatus { + <# + .SYNOPSIS + The HTTP status of a failed Graph request, from the exception or its inner exception; 0 when there is none. + #> + [CmdletBinding()] + [OutputType([int])] + param([Parameter(Mandatory)] [System.Management.Automation.ErrorRecord] $ErrorRecord) + + foreach ($ex in @($ErrorRecord.Exception, $ErrorRecord.Exception.InnerException)) { + if ($ex -and $ex.PSObject.Properties['Response'] -and $ex.Response -and $ex.Response.StatusCode) { return [int]$ex.Response.StatusCode } + } + 0 +} + +function Get-RKCoreBatchBody { + <# + .SYNOPSIS + The body of a required batch response. 404 (an object deleted or referenced by a stale assignment) is + empty with a warning; any other status other than 200 throws. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [pscustomobject] $Response, + [Parameter(Mandatory)] [string] $What, + [Parameter(Mandatory)] [string] $Area, + [Parameter(Mandatory)] [AllowEmptyCollection()] [System.Collections.Generic.List[object]] $Warnings + ) + + if ($Response.Status -eq 200) { return $Response.Body } + if ($Response.Status -eq 404) { + $Warnings.Add([pscustomobject]@{ Area = $Area; Message = "$What was not found (404) and is skipped." }) + return $null + } + throw "Reading $What from Microsoft Graph failed with status $($Response.Status)." +} diff --git a/module/Private/RKCoreGraphAdapter.ps1 b/module/Private/RKCoreGraphAdapter.ps1 new file mode 100644 index 0000000..9a19d02 --- /dev/null +++ b/module/Private/RKCoreGraphAdapter.ps1 @@ -0,0 +1,51 @@ +# Private: the module's implementation of the two Graph adapters module/Core calls. +# The RKSolutions Maester pack has its own implementation (helpers/GraphAdapter.ps1); this file is not synced. + +function Invoke-RKCoreGraphRequest { + <# + .SYNOPSIS + One Graph GET. Without -All: the raw body. With -All: every item of value, following @odata.nextLink. + Throws on an HTTP error. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Uri, + [switch] $All + ) + + $target = ConvertTo-RKCoreGraphPath -Uri $Uri + $next = "https://graph.microsoft.com/$($target.Version)/$($target.Path)" + if (-not $All) { return Invoke-MgGraphRequest -Method GET -Uri $next -OutputType PSObject -ErrorAction Stop } + + $items = [System.Collections.Generic.List[object]]::new() + while ($next) { + $page = Invoke-MgGraphRequest -Method GET -Uri $next -OutputType PSObject -ErrorAction Stop + if ($page.PSObject.Properties['value']) { foreach ($item in @($page.value)) { $items.Add($item) } } + else { $items.Add($page) } + $next = $page.'@odata.nextLink' + } + $items.ToArray() +} + +function Invoke-RKCoreGraphBatch { + <# + .SYNOPSIS + Many Graph GETs through $batch. Returns Id, Status and Body (the raw first page) per request; never throws + for a single request. + #> + [CmdletBinding()] + param([Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Requests) + + # Invoke-RKGraphBatch takes one version per call, so group the requests by the version in their URL. + $byVersion = @{} + foreach ($request in $Requests) { + $target = ConvertTo-RKCoreGraphPath -Uri $request.Url + if (-not $byVersion.ContainsKey($target.Version)) { $byVersion[$target.Version] = [System.Collections.Generic.List[object]]::new() } + $byVersion[$target.Version].Add([pscustomobject]@{ Id = [string]$request.Id; Url = "/$($target.Path)" }) + } + foreach ($version in $byVersion.Keys) { + foreach ($response in (Invoke-RKGraphBatch -Requests $byVersion[$version].ToArray() -GraphVersion $version)) { + [pscustomobject]@{ Id = [string]$response.Id; Status = [int]$response.Status; Body = $response.Body } + } + } +} diff --git a/module/RKSolutions.psm1 b/module/RKSolutions.psm1 index 3dffd9a..ce378f9 100644 --- a/module/RKSolutions.psm1 +++ b/module/RKSolutions.psm1 @@ -30,6 +30,7 @@ if (Test-Path $privatePath) { 'Connect-ToMgGraph.ps1', 'Invoke-GraphRequestWithPaging.ps1', 'Invoke-RKGraphBatch.ps1', + 'RKCoreGraphAdapter.ps1', 'Send-EmailWithAttachment.ps1', 'ConvertTo-DateString.ps1', 'Get-RKSolutionsReportTemplate.ps1', From d209e0c1b7cd205934ded60671a3d98462510dab Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 25 Oct 2026 22:22:59 +0100 Subject: [PATCH 03/24] fix(core): status code from direct StatusCode, guard repeated nextLink, tidy blank lines Co-Authored-By: Claude Opus 5.5 --- Tests/IntuneAnomalyCore.Tests.ps1 | 28 ++++++++++++++++++++++++ module/Core/RKCoreGraph.ps1 | 4 +++- module/Private/IntuneAnomalies.ps1 | 1 - module/Private/IntuneEnrollmentFlows.ps1 | 1 - module/Private/RKCoreGraphAdapter.ps1 | 2 ++ 5 files changed, 33 insertions(+), 3 deletions(-) diff --git a/Tests/IntuneAnomalyCore.Tests.ps1 b/Tests/IntuneAnomalyCore.Tests.ps1 index 0fc91f5..268c8b5 100644 --- a/Tests/IntuneAnomalyCore.Tests.ps1 +++ b/Tests/IntuneAnomalyCore.Tests.ps1 @@ -21,6 +21,28 @@ Describe 'ConvertTo-RKCoreGraphPath' { } } +Describe 'Get-RKCoreGraphErrorStatus' { + It 'Reads Response.StatusCode' { + InModuleScope RKSolutions { + $e = [System.Exception]::new('x') + $e | Add-Member -NotePropertyName Response -NotePropertyValue ([pscustomobject]@{ StatusCode = 404 }) + Get-RKCoreGraphErrorStatus -ErrorRecord ([System.Management.Automation.ErrorRecord]::new($e, 'id', 'NotSpecified', $null)) | Should -Be 404 + } + } + It 'Reads StatusCode from an HttpRequestException' { + InModuleScope RKSolutions { + $e = [System.Net.Http.HttpRequestException]::new('x', $null, [System.Net.HttpStatusCode]::Forbidden) + Get-RKCoreGraphErrorStatus -ErrorRecord ([System.Management.Automation.ErrorRecord]::new($e, 'id', 'NotSpecified', $null)) | Should -Be 403 + } + } + It 'Returns 0 without a status' { + InModuleScope RKSolutions { + $e = [System.Exception]::new('x') + Get-RKCoreGraphErrorStatus -ErrorRecord ([System.Management.Automation.ErrorRecord]::new($e, 'id', 'NotSpecified', $null)) | Should -Be 0 + } + } +} + Describe 'Get-RKCoreBatchBody' { It 'Returns the body for 200' { InModuleScope RKSolutions { @@ -53,6 +75,12 @@ Describe 'Module adapters' { @(Invoke-RKCoreGraphRequest -Uri 'beta/x' -All) | Should -Be @(1, 2, 3) } } + It 'Invoke-RKCoreGraphRequest -All throws when a nextLink repeats' { + InModuleScope RKSolutions { + Mock Invoke-MgGraphRequest { [pscustomobject]@{ value = @(1); '@odata.nextLink' = 'https://graph.microsoft.com/beta/x?skip=1' } } + { Invoke-RKCoreGraphRequest -Uri 'beta/x' -All } | Should -Throw '*same @odata.nextLink*' + } + } It 'Invoke-RKCoreGraphRequest without -All returns the raw body' { InModuleScope RKSolutions { Mock Invoke-MgGraphRequest { [pscustomobject]@{ value = @(1); '@odata.nextLink' = 'n' } } diff --git a/module/Core/RKCoreGraph.ps1 b/module/Core/RKCoreGraph.ps1 index 4f98a85..946de0f 100644 --- a/module/Core/RKCoreGraph.ps1 +++ b/module/Core/RKCoreGraph.ps1 @@ -22,7 +22,9 @@ function Get-RKCoreGraphErrorStatus { param([Parameter(Mandatory)] [System.Management.Automation.ErrorRecord] $ErrorRecord) foreach ($ex in @($ErrorRecord.Exception, $ErrorRecord.Exception.InnerException)) { - if ($ex -and $ex.PSObject.Properties['Response'] -and $ex.Response -and $ex.Response.StatusCode) { return [int]$ex.Response.StatusCode } + if (-not $ex) { continue } + if ($ex.PSObject.Properties['Response'] -and $ex.Response -and $ex.Response.StatusCode) { return [int]$ex.Response.StatusCode } + if ($ex.PSObject.Properties['StatusCode'] -and $ex.StatusCode) { return [int]$ex.StatusCode } } 0 } diff --git a/module/Private/IntuneAnomalies.ps1 b/module/Private/IntuneAnomalies.ps1 index 82269d9..7ca3a03 100644 --- a/module/Private/IntuneAnomalies.ps1 +++ b/module/Private/IntuneAnomalies.ps1 @@ -1845,7 +1845,6 @@ function Get-AutopilotProfilesInformation { # IntuneEnrollmentFlows.ps1 - load order is enforced in RKSolutions.psm1. # ---------------------------------------------------------------------------- - function Get-BitLockerLapsAssignmentContext { <# Bulk-fetches all Settings Catalog and legacy device configuration policies that diff --git a/module/Private/IntuneEnrollmentFlows.ps1 b/module/Private/IntuneEnrollmentFlows.ps1 index 300d023..0d9cd75 100644 --- a/module/Private/IntuneEnrollmentFlows.ps1 +++ b/module/Private/IntuneEnrollmentFlows.ps1 @@ -6,7 +6,6 @@ $script:GroupMemberTypeCache = $null # Shared helper: convert boolean/null to Yes/No/dash $script:ToYesNo = { param($v) if ($null -eq $v) { "-" } elseif ($v -eq $true) { "Yes" } elseif ($v -eq $false) { "No" } else { $v.ToString() } } - # Single source of truth for Graph device $select (Section 8.1) $script:ManagedDeviceSelectProperties = @( 'AadRegistered', 'hardwareInformation', 'ActivationLockBypassCode', 'AndroidSecurityPatchLevel', 'AssignmentFilterEvaluationStatusDetails', diff --git a/module/Private/RKCoreGraphAdapter.ps1 b/module/Private/RKCoreGraphAdapter.ps1 index 9a19d02..7852ed9 100644 --- a/module/Private/RKCoreGraphAdapter.ps1 +++ b/module/Private/RKCoreGraphAdapter.ps1 @@ -18,7 +18,9 @@ function Invoke-RKCoreGraphRequest { if (-not $All) { return Invoke-MgGraphRequest -Method GET -Uri $next -OutputType PSObject -ErrorAction Stop } $items = [System.Collections.Generic.List[object]]::new() + $seen = [System.Collections.Generic.HashSet[string]]::new() while ($next) { + if (-not $seen.Add($next)) { throw "Graph returned the same @odata.nextLink twice for $Uri." } $page = Invoke-MgGraphRequest -Method GET -Uri $next -OutputType PSObject -ErrorAction Stop if ($page.PSObject.Properties['value']) { foreach ($item in @($page.value)) { $items.Add($item) } } else { $items.Add($page) } From 940f7e0f59afd32714b09d7690ff8383d3c9860a Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 25 Oct 2026 22:24:14 +0100 Subject: [PATCH 04/24] refactor(core): move Get-GroupInfo, Get-DetailedPolicyAssignments and Test-IntuneFilter into the core Co-Authored-By: Claude Sonnet 5.5 --- module/Core/IntunePolicyAssignment.ps1 | 223 +++++++++++++++++++++++ module/Private/IntuneEnrollmentFlows.ps1 | 221 ---------------------- 2 files changed, 223 insertions(+), 221 deletions(-) create mode 100644 module/Core/IntunePolicyAssignment.ps1 diff --git a/module/Core/IntunePolicyAssignment.ps1 b/module/Core/IntunePolicyAssignment.ps1 new file mode 100644 index 0000000..ab0046c --- /dev/null +++ b/module/Core/IntunePolicyAssignment.ps1 @@ -0,0 +1,223 @@ +function Get-GroupInfo { + param([Parameter(Mandatory)][string]$GroupId) + if ($script:AllGroups.Count -gt 0) { + $group = $script:AllGroups[$GroupId] + if ($group) { return @{ Id = $group.id; DisplayName = $group.displayName; Success = $true } } + } + try { + $group = Invoke-RKCoreGraphRequest -Uri "v1.0/groups/$GroupId" + return @{ Id = $group.id; DisplayName = $group.displayName; Success = $true } + } + catch { + return @{ Id = $GroupId; DisplayName = "Unknown Group"; Success = $false } + } +} + +function Get-DetailedPolicyAssignments { + param([Parameter(Mandatory)][string]$EntityType, [string]$EntityId, [string]$PolicyName, [switch]$DebugMode, [switch]$ThrowOnError) + + $assignmentsUri = $null + if ($EntityType -eq "deviceAppManagement/managedAppPolicies") { + try { + $r = Invoke-RKCoreGraphRequest -Uri "beta/deviceAppManagement/managedAppPolicies/$EntityId" + $path = switch ($r.'@odata.type') { + "#microsoft.graph.androidManagedAppProtection" { "androidManagedAppProtections" } + "#microsoft.graph.iosManagedAppProtection" { "iosManagedAppProtections" } + "#microsoft.graph.windowsManagedAppProtection" { "windowsManagedAppProtections" } + default { $null } + } + if ($path) { $assignmentsUri = "beta/deviceAppManagement/$path('$EntityId')/assignments" } + } + catch { return @() } + } + elseif ($EntityType -eq "mobileAppConfigurations") { + $assignmentsUri = "beta/deviceAppManagement/mobileAppConfigurations('$EntityId')/assignments" + } + elseif ($EntityType -eq "deviceAppManagement/mobileApps") { + $assignmentsUri = "beta/deviceAppManagement/mobileApps('$EntityId')/assignments" + } + elseif ($EntityType -like "deviceAppManagement/*ManagedAppProtections") { + $assignmentsUri = "beta/$EntityType('$EntityId')/assignments" + } + elseif ($EntityType -eq "deviceManagement/intents") { + $assignmentsUri = "beta/deviceManagement/intents/$EntityId/assignments" + } + elseif ($EntityType -eq "deviceManagement/templates") { + $assignmentsUri = "beta/deviceManagement/templates/$EntityId/assignments" + } + elseif ($EntityType -like "virtualEndpoint/*") { + $assignmentsUri = "beta/deviceManagement/$EntityType/$EntityId/assignments" + } + elseif ($EntityType -eq "configurationPolicies") { + $assignmentsUri = "beta/deviceManagement/configurationPolicies/$EntityId/assignments" + } + else { + $assignmentsUri = "beta/deviceManagement/$EntityType('$EntityId')/assignments" + } + + if (-not $assignmentsUri) { return @() } + + try { + Write-Verbose " Querying assignments for: $PolicyName ($EntityType, $EntityId)" + Write-Verbose " Assignments URI: $assignmentsUri" + $assignments = @(Invoke-RKCoreGraphRequest -Uri $assignmentsUri -All) + Write-Verbose " Assignments result: $($assignments.Count) assignments returned" + + $detailedAssignments = @() + foreach ($assignment in $assignments) { + $targetType = $null + $targetName = $null + $targetId = $null + $groupId = $null + + if ($assignment.target) { + $targetType = $assignment.target.'@odata.type' + $groupId = $assignment.target.groupId + $targetId = $assignment.target.deviceAndAppManagementAssignmentFilterId + $targetFilterType = $assignment.target.deviceAndAppManagementAssignmentFilterType + + if ($targetType -eq '#microsoft.graph.allLicensedUsersAssignmentTarget') { + $targetName = "All Users" + } + elseif ($targetType -eq '#microsoft.graph.allDevicesAssignmentTarget') { + $targetName = "All Devices" + } + elseif ($targetType -eq '#microsoft.graph.groupAssignmentTarget' -and $groupId) { + $groupInfo = Get-GroupInfo -GroupId $groupId + $targetName = $groupInfo.DisplayName + } + elseif ($targetType -eq '#microsoft.graph.exclusionGroupAssignmentTarget' -and $groupId) { + $groupInfo = Get-GroupInfo -GroupId $groupId + $targetName = $groupInfo.DisplayName + } + } + + $assignmentIntent = "Apply" + if ($assignment.intent) { + switch ($assignment.intent) { + "required" { $assignmentIntent = "Required" } + "available" { $assignmentIntent = "Available" } + "uninstall" { $assignmentIntent = "Uninstall" } + "availableWithoutEnrollment" { $assignmentIntent = "Available (No Enrollment)" } + default { $assignmentIntent = $assignment.intent } + } + } + + # Filter enrichment + $filterId = $null + $filterName = "No Filter" + $filterType = "None" + $filterRule = $null + $filterPlatform = $null + + if ($targetId) { + $filter = $script:AllFilters[$targetId] + if ($filter) { + $filterId = $filter.id + $filterName = $filter.displayName + # Use filterType from assignment target, not from filter object + if ($targetFilterType) { + $filterType = $targetFilterType + } else { + $filterType = $filter.assignmentFilterManagementType + } + $filterRule = $filter.rule + $filterPlatform = $filter.platform + } + } + + # Convert raw OData type to friendly AssignmentType for categorization + $friendlyAssignmentType = if ($script:ODataToFriendly.ContainsKey($targetType)) { $script:ODataToFriendly[$targetType] } else { $targetType } + + $detailedAssignments += [PSCustomObject]@{ + PolicyName = $PolicyName + PolicyId = $EntityId + PolicyType = $EntityType + AssignmentType = $friendlyAssignmentType + AssignmentIntent = $assignmentIntent + TargetName = $targetName + TargetId = $targetId + GroupId = $groupId + FilterId = $filterId + FilterName = $filterName + FilterType = $filterType + FilterRule = $filterRule + FilterPlatform = $filterPlatform + AssignmentId = $assignment.id + } + } + return $detailedAssignments + } + catch { + # The anomaly core passes -ThrowOnError: an unread assignment would make devices look unprotected. + # A 404 means the policy was deleted while we read it, which is the same as no assignments. + if ($ThrowOnError -and (Get-RKCoreGraphErrorStatus -ErrorRecord $_) -ne 404) { throw } + return @() + } +} + +function Test-IntuneFilter { + [CmdletBinding()] + param([Parameter(Mandatory)][string]$FilterRule, [Parameter(Mandatory)][PSCustomObject]$DeviceProperties) + $propertyMapping = @{ + 'cpuArchitecture' = 'ProcessorArchitecture'; 'deviceCategory' = 'DeviceCategoryDisplayName'; 'deviceName' = 'DeviceName'; 'deviceOwnership' = 'OwnerType'; 'deviceTrustType' = 'JoinType' + 'enrollmentProfileName' = 'EnrollmentProfileName'; 'isRooted' = 'JailBroken'; 'manufacturer' = 'Manufacturer'; 'model' = 'Model'; 'operatingSystemVersion' = 'OSVersion'; 'osVersion' = 'OSVersion' + 'deviceType' = 'DeviceType'; 'operatingSystem' = 'OperatingSystem'; 'complianceState' = 'ComplianceState'; 'managementAgent' = 'ManagementAgent'; 'autopilotEnrolled' = 'AutopilotEnrolled' + 'deviceEnrollmentType' = 'DeviceEnrollmentType'; 'chassisType' = 'ChassisType'; 'managementState' = 'ManagementState'; 'deviceRegistrationState' = 'DeviceRegistrationState' + 'azureAdRegistered' = 'AzureAdRegistered'; 'isEncrypted' = 'IsEncrypted'; 'isSupervised' = 'IsSupervised'; 'jailBroken' = 'JailBroken'; 'serialNumber' = 'SerialNumber'; 'processorArchitecture' = 'ProcessorArchitecture' + } + function Test-Condition { + param([string]$PropertyPath, [string]$Operator, [string]$Value) + $property = $PropertyPath.Trim(); if ($property.StartsWith("device.")) { $property = $property.Substring(7) } + $actualPropertyName = if ($propertyMapping.ContainsKey($property.ToLower())) { $propertyMapping[$property.ToLower()] } else { $property } + $actualValue = $null; if ($DeviceProperties.PSObject.Properties.Name -contains $actualPropertyName) { $actualValue = $DeviceProperties.$actualPropertyName } + if ($null -eq $actualValue) { return $false } + $actualValueStr = $actualValue.ToString(); $targetValueStr = $Value.ToString() + switch -Regex ($Operator.Trim()) { + "^-?eq$" { return $actualValueStr -eq $targetValueStr } + "^-?ne$" { return $actualValueStr -ne $targetValueStr } + "^-?contains$" { return $actualValueStr.Contains($targetValueStr) } + "^-?notContains$" { return -not $actualValueStr.Contains($targetValueStr) } + "^-?startsWith$" { return $actualValueStr.StartsWith($targetValueStr) } + "^-?notStartsWith$" { return -not $actualValueStr.StartsWith($targetValueStr) } + "^-?endsWith$" { return $actualValueStr.EndsWith($targetValueStr) } + "^-?notEndsWith$" { return -not $actualValueStr.EndsWith($targetValueStr) } + "^-?match$" { return $actualValueStr -match $targetValueStr } + "^-?notMatch$" { return $actualValueStr -notmatch $targetValueStr } + "^-?in$" { $cleanValue = $targetValueStr -replace '^\[|\]$', ''; $valueArray = $cleanValue -split ',' | ForEach-Object { $_.Trim().Trim('"').Trim("'") }; return $valueArray -contains $actualValueStr } + "^-?notIn$" { $cleanValue = $targetValueStr -replace '^\[|\]$', ''; $valueArray = $cleanValue -split ',' | ForEach-Object { $_.Trim().Trim('"').Trim("'") }; return $valueArray -notcontains $actualValueStr } + "^-?gt$" { try { return [System.Version]$actualValueStr -gt [System.Version]$targetValueStr } catch { return $actualValueStr -gt $targetValueStr } } + "^-?ge$" { try { return [System.Version]$actualValueStr -ge [System.Version]$targetValueStr } catch { return $actualValueStr -ge $targetValueStr } } + "^-?lt$" { try { return [System.Version]$actualValueStr -lt [System.Version]$targetValueStr } catch { return $actualValueStr -lt $targetValueStr } } + "^-?le$" { try { return [System.Version]$actualValueStr -le [System.Version]$targetValueStr } catch { return $actualValueStr -le $targetValueStr } } + default { return $false } + } + } + $filterRule = $FilterRule -replace '\s+', ' ' + $orConditions = @(); $sections = $filterRule -split '\s+or\s+', 0, 'IgnoreCase' + foreach ($section in $sections) { + $andConditions = @(); $andSections = $section -split '\s+and\s+', 0, 'IgnoreCase' + foreach ($andSection in $andSections) { + $cleanSection = $andSection.Trim() + if ($cleanSection.StartsWith("(") -and $cleanSection.EndsWith(")")) { $cleanSection = $cleanSection.Substring(1, $cleanSection.Length - 2).Trim() } + if ($cleanSection -match '^(.+?)\s+(-?\w+)\s+(.+)$') { + $propertyPath = $matches[1].Trim(); if ($propertyPath.StartsWith("device.")) { $propertyPath = $propertyPath.Substring(7) } + $operator = $matches[2].Trim(); $value = $matches[3].Trim() + $cleanValue = $value + if (-not ($value.StartsWith('[') -and $value.EndsWith(']'))) { + $cleanValue = $value.Trim().Trim('"').Trim("'") + } + $andConditions += @{ PropertyPath = $propertyPath; Operator = $operator; Value = $cleanValue } + } + } + $orConditions += @{ AndConditions = $andConditions } + } + foreach ($orCondition in $orConditions) { + $allTrue = $true + foreach ($andCondition in $orCondition.AndConditions) { + if (-not (Test-Condition -PropertyPath $andCondition.PropertyPath -Operator $andCondition.Operator -Value $andCondition.Value)) { $allTrue = $false; break } + } + if ($allTrue) { return $true } + } + return $false +} diff --git a/module/Private/IntuneEnrollmentFlows.ps1 b/module/Private/IntuneEnrollmentFlows.ps1 index 0d9cd75..4e15bef 100644 --- a/module/Private/IntuneEnrollmentFlows.ps1 +++ b/module/Private/IntuneEnrollmentFlows.ps1 @@ -254,162 +254,7 @@ function Get-NestedGroupChainNames { return $names } -function Get-GroupInfo { - param([Parameter(Mandatory)][string]$GroupId) - if ($script:AllGroups.Count -gt 0) { - $group = $script:AllGroups[$GroupId] - if ($group) { return @{ Id = $group.id; DisplayName = $group.displayName; Success = $true } } - } - try { - $group = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/groups/$GroupId" -Method Get - return @{ Id = $group.id; DisplayName = $group.displayName; Success = $true } - } - catch { - return @{ Id = $GroupId; DisplayName = "Unknown Group"; Success = $false } - } -} - #region Assignment collection -function Get-DetailedPolicyAssignments { - param([Parameter(Mandatory)][string]$EntityType, [string]$EntityId, [string]$PolicyName, [switch]$DebugMode) - - $assignmentsUri = $null - if ($EntityType -eq "deviceAppManagement/managedAppPolicies") { - try { - $r = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/beta/deviceAppManagement/managedAppPolicies/$EntityId" -Method Get - $path = switch ($r.'@odata.type') { - "#microsoft.graph.androidManagedAppProtection" { "androidManagedAppProtections" } - "#microsoft.graph.iosManagedAppProtection" { "iosManagedAppProtections" } - "#microsoft.graph.windowsManagedAppProtection" { "windowsManagedAppProtections" } - default { $null } - } - if ($path) { $assignmentsUri = "https://graph.microsoft.com/beta/deviceAppManagement/$path('$EntityId')/assignments" } - } - catch { return @() } - } - elseif ($EntityType -eq "mobileAppConfigurations") { - $assignmentsUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileAppConfigurations('$EntityId')/assignments" - } - elseif ($EntityType -eq "deviceAppManagement/mobileApps") { - $assignmentsUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps('$EntityId')/assignments" - } - elseif ($EntityType -like "deviceAppManagement/*ManagedAppProtections") { - $assignmentsUri = "https://graph.microsoft.com/beta/$EntityType('$EntityId')/assignments" - } - elseif ($EntityType -eq "deviceManagement/intents") { - $assignmentsUri = "https://graph.microsoft.com/beta/deviceManagement/intents/$EntityId/assignments" - } - elseif ($EntityType -eq "deviceManagement/templates") { - $assignmentsUri = "https://graph.microsoft.com/beta/deviceManagement/templates/$EntityId/assignments" - } - elseif ($EntityType -like "virtualEndpoint/*") { - $assignmentsUri = "https://graph.microsoft.com/beta/deviceManagement/$EntityType/$EntityId/assignments" - } - elseif ($EntityType -eq "configurationPolicies") { - $assignmentsUri = "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies/$EntityId/assignments" - } - else { - $assignmentsUri = "https://graph.microsoft.com/beta/deviceManagement/$EntityType('$EntityId')/assignments" - } - - if (-not $assignmentsUri) { return @() } - - try { - Write-Verbose " Querying assignments for: $PolicyName ($EntityType, $EntityId)" - Write-Verbose " Assignments URI: $assignmentsUri" - $assignments = Invoke-GraphRequestWithPaging -Uri $assignmentsUri -Method "GET" -DebugMode:$DebugMode - Write-Verbose " Assignments result: $($assignments.Count) assignments returned" - - $detailedAssignments = @() - foreach ($assignment in $assignments) { - $targetType = $null - $targetName = $null - $targetId = $null - $groupId = $null - - if ($assignment.target) { - $targetType = $assignment.target.'@odata.type' - $groupId = $assignment.target.groupId - $targetId = $assignment.target.deviceAndAppManagementAssignmentFilterId - $targetFilterType = $assignment.target.deviceAndAppManagementAssignmentFilterType - - if ($targetType -eq '#microsoft.graph.allLicensedUsersAssignmentTarget') { - $targetName = "All Users" - } - elseif ($targetType -eq '#microsoft.graph.allDevicesAssignmentTarget') { - $targetName = "All Devices" - } - elseif ($targetType -eq '#microsoft.graph.groupAssignmentTarget' -and $groupId) { - $groupInfo = Get-GroupInfo -GroupId $groupId - $targetName = $groupInfo.DisplayName - } - elseif ($targetType -eq '#microsoft.graph.exclusionGroupAssignmentTarget' -and $groupId) { - $groupInfo = Get-GroupInfo -GroupId $groupId - $targetName = $groupInfo.DisplayName - } - } - - $assignmentIntent = "Apply" - if ($assignment.intent) { - switch ($assignment.intent) { - "required" { $assignmentIntent = "Required" } - "available" { $assignmentIntent = "Available" } - "uninstall" { $assignmentIntent = "Uninstall" } - "availableWithoutEnrollment" { $assignmentIntent = "Available (No Enrollment)" } - default { $assignmentIntent = $assignment.intent } - } - } - - # Filter enrichment - $filterId = $null - $filterName = "No Filter" - $filterType = "None" - $filterRule = $null - $filterPlatform = $null - - if ($targetId) { - $filter = $script:AllFilters[$targetId] - if ($filter) { - $filterId = $filter.id - $filterName = $filter.displayName - # Use filterType from assignment target, not from filter object - if ($targetFilterType) { - $filterType = $targetFilterType - } else { - $filterType = $filter.assignmentFilterManagementType - } - $filterRule = $filter.rule - $filterPlatform = $filter.platform - } - } - - # Convert raw OData type to friendly AssignmentType for categorization - $friendlyAssignmentType = if ($script:ODataToFriendly.ContainsKey($targetType)) { $script:ODataToFriendly[$targetType] } else { $targetType } - - $detailedAssignments += [PSCustomObject]@{ - PolicyName = $PolicyName - PolicyId = $EntityId - PolicyType = $EntityType - AssignmentType = $friendlyAssignmentType - AssignmentIntent = $assignmentIntent - TargetName = $targetName - TargetId = $targetId - GroupId = $groupId - FilterId = $filterId - FilterName = $filterName - FilterType = $filterType - FilterRule = $filterRule - FilterPlatform = $filterPlatform - AssignmentId = $assignment.id - } - } - return $detailedAssignments - } - catch { - return @() - } -} - function Get-DetailedPolicyAssignmentsFromExpanded { param([Parameter(Mandatory)]$Policy, [Parameter(Mandatory)][string]$PolicyName, [Parameter(Mandatory)]$PolicyType, [switch]$DebugMode) @@ -1336,72 +1181,6 @@ function Get-DeviceEvaluationContext { } } -function Test-IntuneFilter { - [CmdletBinding()] - param([Parameter(Mandatory)][string]$FilterRule, [Parameter(Mandatory)][PSCustomObject]$DeviceProperties) - $propertyMapping = @{ - 'cpuArchitecture' = 'ProcessorArchitecture'; 'deviceCategory' = 'DeviceCategoryDisplayName'; 'deviceName' = 'DeviceName'; 'deviceOwnership' = 'OwnerType'; 'deviceTrustType' = 'JoinType' - 'enrollmentProfileName' = 'EnrollmentProfileName'; 'isRooted' = 'JailBroken'; 'manufacturer' = 'Manufacturer'; 'model' = 'Model'; 'operatingSystemVersion' = 'OSVersion'; 'osVersion' = 'OSVersion' - 'deviceType' = 'DeviceType'; 'operatingSystem' = 'OperatingSystem'; 'complianceState' = 'ComplianceState'; 'managementAgent' = 'ManagementAgent'; 'autopilotEnrolled' = 'AutopilotEnrolled' - 'deviceEnrollmentType' = 'DeviceEnrollmentType'; 'chassisType' = 'ChassisType'; 'managementState' = 'ManagementState'; 'deviceRegistrationState' = 'DeviceRegistrationState' - 'azureAdRegistered' = 'AzureAdRegistered'; 'isEncrypted' = 'IsEncrypted'; 'isSupervised' = 'IsSupervised'; 'jailBroken' = 'JailBroken'; 'serialNumber' = 'SerialNumber'; 'processorArchitecture' = 'ProcessorArchitecture' - } - function Test-Condition { - param([string]$PropertyPath, [string]$Operator, [string]$Value) - $property = $PropertyPath.Trim(); if ($property.StartsWith("device.")) { $property = $property.Substring(7) } - $actualPropertyName = if ($propertyMapping.ContainsKey($property.ToLower())) { $propertyMapping[$property.ToLower()] } else { $property } - $actualValue = $null; if ($DeviceProperties.PSObject.Properties.Name -contains $actualPropertyName) { $actualValue = $DeviceProperties.$actualPropertyName } - if ($null -eq $actualValue) { return $false } - $actualValueStr = $actualValue.ToString(); $targetValueStr = $Value.ToString() - switch -Regex ($Operator.Trim()) { - "^-?eq$" { return $actualValueStr -eq $targetValueStr } - "^-?ne$" { return $actualValueStr -ne $targetValueStr } - "^-?contains$" { return $actualValueStr.Contains($targetValueStr) } - "^-?notContains$" { return -not $actualValueStr.Contains($targetValueStr) } - "^-?startsWith$" { return $actualValueStr.StartsWith($targetValueStr) } - "^-?notStartsWith$" { return -not $actualValueStr.StartsWith($targetValueStr) } - "^-?endsWith$" { return $actualValueStr.EndsWith($targetValueStr) } - "^-?notEndsWith$" { return -not $actualValueStr.EndsWith($targetValueStr) } - "^-?match$" { return $actualValueStr -match $targetValueStr } - "^-?notMatch$" { return $actualValueStr -notmatch $targetValueStr } - "^-?in$" { $cleanValue = $targetValueStr -replace '^\[|\]$', ''; $valueArray = $cleanValue -split ',' | ForEach-Object { $_.Trim().Trim('"').Trim("'") }; return $valueArray -contains $actualValueStr } - "^-?notIn$" { $cleanValue = $targetValueStr -replace '^\[|\]$', ''; $valueArray = $cleanValue -split ',' | ForEach-Object { $_.Trim().Trim('"').Trim("'") }; return $valueArray -notcontains $actualValueStr } - "^-?gt$" { try { return [System.Version]$actualValueStr -gt [System.Version]$targetValueStr } catch { return $actualValueStr -gt $targetValueStr } } - "^-?ge$" { try { return [System.Version]$actualValueStr -ge [System.Version]$targetValueStr } catch { return $actualValueStr -ge $targetValueStr } } - "^-?lt$" { try { return [System.Version]$actualValueStr -lt [System.Version]$targetValueStr } catch { return $actualValueStr -lt $targetValueStr } } - "^-?le$" { try { return [System.Version]$actualValueStr -le [System.Version]$targetValueStr } catch { return $actualValueStr -le $targetValueStr } } - default { return $false } - } - } - $filterRule = $FilterRule -replace '\s+', ' ' - $orConditions = @(); $sections = $filterRule -split '\s+or\s+', 0, 'IgnoreCase' - foreach ($section in $sections) { - $andConditions = @(); $andSections = $section -split '\s+and\s+', 0, 'IgnoreCase' - foreach ($andSection in $andSections) { - $cleanSection = $andSection.Trim() - if ($cleanSection.StartsWith("(") -and $cleanSection.EndsWith(")")) { $cleanSection = $cleanSection.Substring(1, $cleanSection.Length - 2).Trim() } - if ($cleanSection -match '^(.+?)\s+(-?\w+)\s+(.+)$') { - $propertyPath = $matches[1].Trim(); if ($propertyPath.StartsWith("device.")) { $propertyPath = $propertyPath.Substring(7) } - $operator = $matches[2].Trim(); $value = $matches[3].Trim() - $cleanValue = $value - if (-not ($value.StartsWith('[') -and $value.EndsWith(']'))) { - $cleanValue = $value.Trim().Trim('"').Trim("'") - } - $andConditions += @{ PropertyPath = $propertyPath; Operator = $operator; Value = $cleanValue } - } - } - $orConditions += @{ AndConditions = $andConditions } - } - foreach ($orCondition in $orConditions) { - $allTrue = $true - foreach ($andCondition in $orCondition.AndConditions) { - if (-not (Test-Condition -PropertyPath $andCondition.PropertyPath -Operator $andCondition.Operator -Value $andCondition.Value)) { $allTrue = $false; break } - } - if ($allTrue) { return $true } - } - return $false -} - function ConvertTo-CanonicalAssignment { param([Parameter(Mandatory)][PSCustomObject]$AssignmentRow) # Map assignment type strings to Graph @odata.type. Unknown types default to All Devices. From bf16f715e744666b0f8b4d68b21fb1536d92f395 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 25 Oct 2026 22:24:40 +0100 Subject: [PATCH 05/24] refactor(core): move the Intune anomaly collector and resolvers into module/Core (no behaviour change) Co-Authored-By: Claude Sonnet 5.5 --- module/Core/IntuneAnomalyCollector.ps1 | 850 +++++++++++++++ module/Core/IntuneAnomalyResolvers.ps1 | 478 +++++++++ module/Private/IntuneAnomalies.ps1 | 1331 ------------------------ 3 files changed, 1328 insertions(+), 1331 deletions(-) create mode 100644 module/Core/IntuneAnomalyCollector.ps1 create mode 100644 module/Core/IntuneAnomalyResolvers.ps1 diff --git a/module/Core/IntuneAnomalyCollector.ps1 b/module/Core/IntuneAnomalyCollector.ps1 new file mode 100644 index 0000000..2615390 --- /dev/null +++ b/module/Core/IntuneAnomalyCollector.ps1 @@ -0,0 +1,850 @@ +function Get-AllDeviceData { + function Get-OperatingSystemProductType { + param ( + $Customer + ) + + @{ + "0" = "unknown" + "4" = "Windows 10/11 Enterprise" + "27" = "Windows 10/11 Enterprise N" + "48" = "Windows 10/11 Professional" + "49" = "Windows 10/11 Professional for workstation N" + "72" = "Windows 10/11 Enterprise Evaluation" + "119" = "Windows 10 TeamOS" + "121" = "Windows 10/11 Education" + "122" = "Windows 10/11 Education N" + "125" = "Windows 10 Enterprise LTSC" + "136" = "Hololens" + "175" = "Windows 10 / 11 Enterprise Multi-session" + }.$Customer + } + + function Get-OSFriendlyName { + param ( + [string]$OperatingSystemVersion + ) + + switch -Regex ($OperatingSystemVersion) { + "^10\.0\.19043" { return "Windows 10 21H1" } + "^10\.0\.19044" { return "Windows 10 21H2" } + "^10\.0\.19045" { return "Windows 10 22H2" } + "^10\.0\.22000" { return "Windows 11 21H2" } + "^10\.0\.22621" { return "Windows 11 22H2" } + "^10\.0\.22631" { return "Windows 11 23H2" } + "^10\.0\.22635" { return "Windows 11 23H2 Insider Preview" } + "^10\.0\.261" { return "Windows 11 24H2" } + "^10\.0\.262" { return "Windows 11 25H2" } + default { return "Other" } + } + } + + function Convert-Size { + [cmdletbinding()] + param( + [validateset("Bytes", "KB", "MB", "GB", "TB")] + [string]$From, + [validateset("Bytes", "KB", "MB", "GB", "TB")] + [string]$To, + [Parameter(Mandatory = $true)] + [double]$Value, + [int]$Precision = 4 + ) + switch ($From) { + "Bytes" { $value = $Value } + "KB" { $value = $Value * 1024 } + "MB" { $value = $Value * 1024 * 1024 } + "GB" { $value = $Value * 1024 * 1024 * 1024 } + "TB" { $value = $Value * 1024 * 1024 * 1024 * 1024 } + } + + switch ($To) { + "Bytes" { return $value } + "KB" { $Value = $Value / 1KB } + "MB" { $Value = $Value / 1MB } + "GB" { $Value = $Value / 1GB } + "TB" { $Value = $Value / 1TB } + + } + + $Calc = [Math]::Round($value, $Precision, [MidPointRounding]::AwayFromZero) + return "$calc $to" + + } + + # Optimized Properties List - Only essential properties for better performance + $Properties = @( + 'Id', # Required for compliance data fetching and unique identification + 'DeviceName', + 'azureADDeviceId', # Canonical casing - the join key for BitLocker keys + LAPS credentials + 'azureActiveDirectoryDeviceId', # Legacy fallback property; same GUID on modern tenants + 'ManagedDeviceOwnerType', + 'UserPrincipalName', # Primary user + 'SerialNumber', + 'ManagedDeviceName', + 'Manufacturer', + 'Model', + 'ProcessorArchitecture', + 'WiFiMacAddress', + 'EthernetMacAddress', + 'TotalStorageSpaceInBytes', + 'FreeStorageSpaceInBytes', + 'EnrolledDateTime', + 'LastSyncDateTime', + 'EnrollmentProfileName', + 'IsEncrypted', + 'DeviceEnrollmentType', + 'OperatingSystem', + 'OSVersion', + 'ComplianceState', + 'usersLoggedOn', # Contains userId for logged-on users + 'hardwareInformation', # Contains nested properties like tpmVersion, OS details, BiosVersion + 'managementAgent', # Indicates the management agent used (e.g., Intune) + 'skuFamily' # OS edition (Pro, Enterprise, Home, etc.) - more reliable than hardwareInformation.operatingSystemEdition + ) + + $swTotal = [System.Diagnostics.Stopwatch]::StartNew() + + # Get all Windows Devices from Microsoft Intune + $swStep = [System.Diagnostics.Stopwatch]::StartNew() + $AllDeviceData = Invoke-graphRequestWithPaging -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=operatingSystem eq 'Windows'&`$select=$($Properties -join ',')" + #filter out managed by MDE + $AllDeviceData = $AllDeviceData | Where-Object { $_.managementAgent -ne "msSense" } + $swStep.Stop() + Write-Verbose ("[Get-AllDeviceData] Managed devices fetch: {0:N2}s ({1} devices)" -f $swStep.Elapsed.TotalSeconds, $AllDeviceData.Count) + + # The list endpoint always reports deviceType 'windowsRT'; only a per-device GET returns 'cloudPC' + # (Windows 365 and Dev Box). A failed lookup counts as a physical device so nothing is hidden by mistake. + $swStep.Restart() + $CloudPcIds = [System.Collections.Generic.HashSet[string]]::new() + $deviceTypeRequests = foreach ($d in $AllDeviceData) { + [PSCustomObject]@{ Id = "dt:$($d.id)"; Url = "/deviceManagement/managedDevices/$($d.id)?`$select=id,deviceType" } + } + foreach ($resp in (Invoke-RKGraphBatch -Requests @($deviceTypeRequests) -Activity 'Device types')) { + if ($resp.Status -eq 200 -and $resp.Body.deviceType -eq 'cloudPC') { [void]$CloudPcIds.Add(($resp.Id -replace '^dt:', '')) } + } + $swStep.Stop() + Write-Verbose ("[Get-AllDeviceData] Device type lookup: {0:N2}s ({1} Cloud PCs)" -f $swStep.Elapsed.TotalSeconds, $CloudPcIds.Count) + + # Get all AutoPilot registered devices under "Enrollment" + Write-Host "Fetching Autopilot devices..." -ForegroundColor Yellow + $swStep.Restart() + $AutopilotDevices = (Invoke-GraphRequestWithPaging -Uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities") + $swStep.Stop() + Write-Verbose ("[Get-AllDeviceData] Autopilot devices fetch: {0:N2}s ({1} devices)" -f $swStep.Elapsed.TotalSeconds, $AutopilotDevices.Count) + + # Pre-build Autopilot lookup hashtable (serialNumber -> device object) for O(1) lookups + $AutopilotLookup = @{} + foreach ($ap in $AutopilotDevices) { + if ($ap.serialNumber) { $AutopilotLookup[$ap.serialNumber] = $ap } + } + + # Autopilot device preparation (v2) associates devices via corporate identifiers + # ("Manufacturer,Model,Serial" or a bare serial) instead of a hardware hash. + $CorporateIdentifierSerials = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $CorporateIdentifiers = Invoke-GraphRequestWithPaging -Uri "https://graph.microsoft.com/beta/deviceManagement/importedDeviceIdentities" + foreach ($ci in $CorporateIdentifiers) { + if ($ci.importedDeviceIdentifier) { [void]$CorporateIdentifierSerials.Add(($ci.importedDeviceIdentifier -split ',')[-1].Trim()) } + } + + # Pre-build user lookup hashtable (id -> UPN) for O(1) lookups + $UserLookup = @{} + foreach ($u in $AllEntraIDUsers) { + if ($u.id -and $u.userPrincipalName) { $UserLookup[$u.id] = $u.userPrincipalName } + } + + # Pre-fetch compliance rules for all noncompliant devices via Graph $batch. + # Two batched passes replace 1+N per-device round trips: first the policy + # state list per device, then the settingStates per nonCompliant policy. + $ComplianceRulesByDevice = @{} + $NonCompliantDevices = $AllDeviceData | Where-Object { $_.complianceState -eq 'noncompliant' } + if ($NonCompliantDevices -and $NonCompliantDevices.Count -gt 0) { + # Module-scoped map: reset so a previous tenant's day count never leaks into this report. + $script:ComplianceSettingFriendlyNames['RequireRemainContact'] = 'Is active (no compliance check-in within the compliance status validity period)' + try { + $checkinDays = (Invoke-MgGraphRequest -Uri 'https://graph.microsoft.com/beta/deviceManagement/settings' -OutputType PSObject -ErrorAction Stop).deviceComplianceCheckinThresholdDays + if ($checkinDays) { $script:ComplianceSettingFriendlyNames['RequireRemainContact'] = "Is active (no compliance check-in in the last $checkinDays days)" } + } catch { + Write-Verbose "Compliance validity period lookup failed: $($_.Exception.Message)" + } + Write-Host "Fetching compliance details for $($NonCompliantDevices.Count) noncompliant devices..." -ForegroundColor Yellow + + $policyStateRequests = foreach ($d in $NonCompliantDevices) { + [PSCustomObject]@{ + Id = "ps:$($d.id)" + Url = "/deviceManagement/managedDevices/$($d.id)/deviceCompliancePolicyStates" + } + } + + $swStep.Restart() + $policyStateResponses = Invoke-RKGraphBatch -Requests @($policyStateRequests) -Activity "Compliance policy states" + $swStep.Stop() + Write-Verbose ("[Get-AllDeviceData] Compliance policy-states batch: {0:N2}s ({1} devices, {2} responses)" -f $swStep.Elapsed.TotalSeconds, $NonCompliantDevices.Count, $policyStateResponses.Count) + + # Map deviceId -> array of nonCompliant/Error policy state ids (preserving the existing <=10 guard). + # Graph occasionally returns the same policy-state id twice for a single device (e.g. when the + # same policy resolves via more than one assignment path). Adding both would produce duplicate + # sub-request ids in the batch, which Graph rejects with 400 "Request Id ... has to be unique + # in a batch." Dedupe the state list per device by id BEFORE the <=10 guard so duplicates + # don't accidentally push a device past the cap and silently drop its compliance details. + $settingStatePairs = [System.Collections.Generic.List[object]]::new() + foreach ($resp in $policyStateResponses) { + if ($resp.Status -ne 200 -or -not $resp.Body) { continue } + $deviceId = $resp.Id -replace '^ps:', '' + $rawStates = @($resp.Body.value | Where-Object { $_.State -eq 'nonCompliant' -or $_.State -eq 'Error' }) + + $seenStateIds = [System.Collections.Generic.HashSet[string]]::new() + $states = [System.Collections.Generic.List[object]]::new() + foreach ($s in $rawStates) { + if ($seenStateIds.Add($s.id)) { $states.Add($s) } + } + + if ($states.Count -eq 0 -or $states.Count -gt 10) { continue } + $ComplianceRulesByDevice[$deviceId] = [System.Collections.Generic.List[string]]::new() + foreach ($s in $states) { + $settingStatePairs.Add([PSCustomObject]@{ + Id = "ss:$deviceId|$($s.id)" + Url = "/deviceManagement/managedDevices/$deviceId/deviceCompliancePolicyStates/$($s.id)/settingStates" + }) + } + } + + if ($settingStatePairs.Count -gt 0) { + $swStep.Restart() + $settingResponses = Invoke-RKGraphBatch -Requests @($settingStatePairs) -Activity "Compliance setting states" + $swStep.Stop() + Write-Verbose ("[Get-AllDeviceData] Compliance setting-states batch: {0:N2}s ({1} pairs)" -f $swStep.Elapsed.TotalSeconds, $settingStatePairs.Count) + + $failedPairs = [System.Collections.Generic.List[object]]::new() + $pairIndex = @{} + foreach ($p in $settingStatePairs) { $pairIndex[$p.Id] = $p } + + foreach ($resp in $settingResponses) { + if ($resp.Status -ne 200 -or -not $resp.Body) { + if ($pairIndex.ContainsKey($resp.Id)) { $failedPairs.Add($pairIndex[$resp.Id]) } + continue + } + $deviceId = ($resp.Id -replace '^ss:', '') -split '\|' | Select-Object -First 1 + if (-not $ComplianceRulesByDevice.ContainsKey($deviceId)) { continue } + $details = @($resp.Body.value | Where-Object { $_.state -match 'nonCompliant' }) + foreach ($det in $details) { + # settingName (e.g. Windows10CompliancePolicy.RtpEnabled) is always set and maps to a friendly name; `setting` is empty for Default Device Compliance Policy rows. + $rule = if ($det.settingName) { $det.settingName } else { $det.setting } + if ($rule) { $ComplianceRulesByDevice[$deviceId].Add($rule) } + } + } + + # Fallback: sub-requests that didn't return 200 from $batch get retried as + # per-request GETs. Graph's $batch endpoint occasionally rejects this + # setting-states sub-route with 400 even though the individual GET works. + if ($failedPairs.Count -gt 0) { + $swStep.Restart() + foreach ($pair in $failedPairs) { + $deviceId = ($pair.Id -replace '^ss:', '') -split '\|' | Select-Object -First 1 + if (-not $ComplianceRulesByDevice.ContainsKey($deviceId)) { continue } + try { + $direct = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta$($pair.Url)" -ErrorAction Stop + $details = @($direct.value | Where-Object { $_.state -match 'nonCompliant' }) + foreach ($det in $details) { + $rule = if ($det.settingName) { $det.settingName } else { $det.setting } + if ($rule) { $ComplianceRulesByDevice[$deviceId].Add($rule) } + } + } catch { + Write-Verbose "Compliance setting-states fallback: per-request GET failed for $($pair.Id): $($_.Exception.Message)" + } + } + $swStep.Stop() + Write-Verbose ("[Get-AllDeviceData] Compliance setting-states fallback: {0:N2}s ({1} pairs retried individually)" -f $swStep.Elapsed.TotalSeconds, $failedPairs.Count) + } + } + } + + # Loop through all devices for device data + $results = [System.Collections.Generic.List[PSObject]]::new() + $totalDevices = $AllDeviceData.Count + + Write-Host "Processing $totalDevices devices..." -ForegroundColor Yellow + $swStep.Restart() + + for ($i = 0; $i -lt $AllDeviceData.Count; $i++) { + $DeviceData = $AllDeviceData[$i] + $currentIndex = $i + 1 + + # Calculate progress percentage + $progressPercent = [math]::Round(($currentIndex / $totalDevices) * 100, 1) + + # Show progress bar instead of Write-Host + Write-Progress -Activity "Processing Intune Devices" -Status "Processing device: $($DeviceData.DeviceName)" -CurrentOperation "$currentIndex of $totalDevices devices processed" -PercentComplete $progressPercent + + try { + # Use bulk-fetched data directly (no per-device re-fetch needed - same $select) + $DeviceProperties = $DeviceData + + # Process Autopilot information via pre-built hashtable + $AutopilotInfo = $AutopilotLookup[$DeviceData.SerialNumber] + $HashUploaded = $AutopilotLookup.ContainsKey($DeviceData.SerialNumber) + + # Compliance rules were prefetched via $batch above; just look up. + $FilteredForAlerting = @("DefaultDeviceCompliancePolicy.RequireDeviceCompliancePolicyAssigned", "DefaultDeviceCompliancePolicy.RequireRemainContact") + $uniqueRules = @() + if ($DeviceData.complianceState -eq 'noncompliant' -and $ComplianceRulesByDevice.ContainsKey($DeviceData.id)) { + $uniqueRules = @($ComplianceRulesByDevice[$DeviceData.id] | Select-Object -Unique) + } + + # Check if all logged in user ID's still exist in Microsoft Entra ID + $LoggedInUsers = $DeviceProperties.usersLoggedOn.userId | Select-Object -Unique + $ExistingLoggedInUsers = [System.Collections.Generic.List[string]]::new() + + if ($LoggedInUsers) { + foreach ($user in $LoggedInUsers) { + if ($UserLookup.ContainsKey($user)) { + $ExistingLoggedInUsers.Add($UserLookup[$user]) + } + } + } + + # Handle storage calculations with null checking + $TotalStorageFormatted = if ($DeviceProperties.TotalStorageSpaceInBytes -and $DeviceProperties.TotalStorageSpaceInBytes -gt 0) { + Convert-Size -From bytes -To GB -Value $DeviceProperties.TotalStorageSpaceInBytes -Precision 2 + } else { + "N/A" + } + + $FreeStorageFormatted = if ($DeviceProperties.FreeStorageSpaceInBytes -and $DeviceProperties.FreeStorageSpaceInBytes -gt 0) { + Convert-Size -From bytes -To GB -Value $DeviceProperties.FreeStorageSpaceInBytes -Precision 2 + } else { + "N/A" + } + + # Access hardware information with null checking + $hardwareInfo = $DeviceProperties.hardwareInformation + + # Read the Azure AD device id defensively. Graph normalises the property to + # `azureADDeviceId` (capital AD); some object types lose that on dot-access, + # and on a few records only the legacy `azureActiveDirectoryDeviceId` is set. + $resolvedAadDeviceId = $null + foreach ($propName in 'azureADDeviceId','azureAdDeviceId','AzureAdDeviceId','azureActiveDirectoryDeviceId','AzureActiveDirectoryDeviceId') { + $p = $DeviceProperties.PSObject.Properties[$propName] + if ($p -and $p.Value) { $resolvedAadDeviceId = [string]$p.Value; break } + } + + $results.Add([PSCustomObject][ordered]@{ + Customer = $TenantName + DeviceName = $DeviceProperties.DeviceName + AzureAdDeviceId = $resolvedAadDeviceId + DeviceOwnership = $DeviceProperties.ManagedDeviceOwnerType + PrimaryUser = if ($DeviceProperties.UserPrincipalName) { $DeviceProperties.UserPrincipalName } else { "None" } + Serialnumber = $DeviceProperties.SerialNumber + DeviceManufacturer = $DeviceProperties.Manufacturer + DeviceModel = $DeviceProperties.Model + ProcessorArchitecture = if ($hardwareInfo.processorArchitecture) { $hardwareInfo.processorArchitecture } else { $DeviceProperties.processorArchitecture } + TPMversion = if ($hardwareInfo.tpmVersion) { $hardwareInfo.tpmVersion } else { "Unknown" } + tpmSpecificationVersion = if ($hardwareInfo.tpmSpecificationVersion) { $hardwareInfo.tpmSpecificationVersion } else { "Unknown" } + WiFiMAC = $DeviceProperties.WiFiMacAddress + EthernetMAC = $DeviceProperties.EthernetMacAddress + TotalStorage = $TotalStorageFormatted + FreeStorage = $FreeStorageFormatted + EnrolledDate = $DeviceProperties.EnrolledDateTime + LastContact = $DeviceProperties.LastSyncDateTime + DeviceHashUploaded = $HashUploaded + DeviceAssociated = [bool]($DeviceData.SerialNumber -and $CorporateIdentifierSerials.Contains($DeviceData.SerialNumber)) + IsCloudPC = $CloudPcIds.Contains([string]$DeviceData.id) + AutopilotGroupTag = $AutopilotInfo.groupTag + AutopilotAssignedUser = if ($AutopilotInfo.userprincipalname) { $AutopilotInfo.userprincipalname } else { $null } + EnrollmentProfile = $DeviceProperties.EnrollmentProfileName + Encrypted = $DeviceProperties.IsEncrypted + DeviceEnrollmentType = $DeviceProperties.DeviceEnrollmentType + usersLoggedOnIds = if ($ExistingLoggedInUsers) { $ExistingLoggedInUsers -join ', ' } else { "" } + usersLoggedOnCount = if ($LoggedInUsers) { $LoggedInUsers.Count } else { 0 } + Operatingsystem = $DeviceProperties.OperatingSystem + OperatingSystemVersion = $DeviceProperties.OSVersion + OSFriendlyname = Get-OSFriendlyName -OperatingSystemVersion $DeviceProperties.OSVersion + OperatingSystemLanguage = if ($hardwareInfo.operatingSystemLanguage) { $hardwareInfo.operatingSystemLanguage } else { "Unknown" } + OperatingSystemEdition = if ($DeviceProperties.skuFamily) { $DeviceProperties.skuFamily } elseif ($hardwareInfo.operatingSystemEdition) { $hardwareInfo.operatingSystemEdition } else { "Unknown" } + operatingSystemProductType = if ($hardwareInfo.operatingSystemProductType) { Get-OperatingSystemProductType -Customer "$($hardwareInfo.operatingSystemProductType)" } else { "Unknown" } + BiosVersion = if ($hardwareInfo.systemManagementBIOSVersion) { $hardwareInfo.systemManagementBIOSVersion } else { "Unknown" } + ComplianceStatus = $DeviceProperties.ComplianceState + # **FIX**: Use unique rules to prevent duplicates + NoncompliantBasedOn = (@($uniqueRules | ForEach-Object { ConvertTo-ComplianceSettingFriendlyName $_ }) | Select-Object -Unique) -join ', ' + NoncompliantAlert = (@($uniqueRules | Where-Object { $_ -notin $FilteredForAlerting } | ForEach-Object { ConvertTo-ComplianceSettingFriendlyName $_ }) | Select-Object -Unique) -join ', ' + }) + } catch { + Write-Warning "Error processing device $($DeviceData.DeviceName): $_" + continue + } + } + + # Clear the progress bar when done + Write-Progress -Activity "Processing Intune Devices" -Completed + $swStep.Stop() + Write-Verbose ("[Get-AllDeviceData] Per-device projection loop: {0:N2}s" -f $swStep.Elapsed.TotalSeconds) + $swTotal.Stop() + Write-Verbose ("[Get-AllDeviceData] TOTAL: {0:N2}s" -f $swTotal.Elapsed.TotalSeconds) + + Write-Host "Device processing completed!" -ForegroundColor Green + Write-Host "Processed $($results.Count) devices out of $totalDevices total devices" -ForegroundColor Green + + # Debug output for hardware information availability + $devicesWithoutHardwareInfo = $results | Where-Object { $_.TPMversion -eq "Unknown" } + + if ($devicesWithoutHardwareInfo.Count -gt 0) { + Write-Host "Some devices are missing hardware information:" -ForegroundColor Yellow + foreach ($device in $devicesWithoutHardwareInfo) { + Write-Host " - $($device.DeviceName) (Serial: $($device.Serialnumber))" -ForegroundColor Gray + } + } + return $results + +} + +function Get-ApplicationFailures { + + # Cross-platform temporary file path + # Detect OS and set appropriate temp path + $detectedWindows = $false + + # Check if automatic variables exist (PowerShell Core 6.0+) + if (Get-Variable -Name "IsWindows" -ErrorAction SilentlyContinue) { + $detectedWindows = $IsWindows + } + # Fallback for older PowerShell versions + else { + $osInfo = [System.Environment]::OSVersion.Platform + switch ($osInfo) { + "Win32NT" { $detectedWindows = $true } + "Unix" { + # macOS/Linux detected but not used in this context + } + default { + try { + if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { + $detectedWindows = $true + } + else { + # Non-Windows OS detected + } + } catch { + $detectedWindows = $true + } + } + } + } + + # Use a unique temporary file to avoid race conditions + $Data = [System.IO.Path]::GetTempFileName() + + $apps = (Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?`$filter=(isof(%27microsoft.graph.win32CatalogApp%27)%20or%20isof(%27microsoft.graph.windowsStoreApp%27)%20or%20isof(%27microsoft.graph.microsoftStoreForBusinessApp%27)%20or%20isof(%27microsoft.graph.officeSuiteApp%27)%20or%20(isof(%27microsoft.graph.win32LobApp%27)%20and%20not(isof(%27microsoft.graph.win32CatalogApp%27)))%20or%20isof(%27microsoft.graph.windowsMicrosoftEdgeApp%27)%20or%20isof(%27microsoft.graph.windowsPhone81AppX%27)%20or%20isof(%27microsoft.graph.windowsPhone81StoreApp%27)%20or%20isof(%27microsoft.graph.windowsPhoneXAP%27)%20or%20isof(%27microsoft.graph.windowsAppX%27)%20or%20isof(%27microsoft.graph.windowsMobileMSI%27)%20or%20isof(%27microsoft.graph.windowsUniversalAppX%27)%20or%20isof(%27microsoft.graph.webApp%27)%20or%20isof(%27microsoft.graph.windowsWebApp%27)%20or%20isof(%27microsoft.graph.winGetApp%27))%20and%20(microsoft.graph.managedApp/appAvailability%20eq%20null%20or%20microsoft.graph.managedApp/appAvailability%20eq%20%27lineOfBusiness%27%20or%20isAssigned%20eq%20true)&`$orderby=displayName&").value + + $params = @{ + Select = @( + "DisplayName" + "Publisher" + "Platform" + "AppVersion" + "FailedDevicePercentage" + "FailedDeviceCount" + "FailedUserCount" + "ApplicationId" + ) + Skip = 0 + Top = 50 + Filter = "(FailedDeviceCount gt '0')" + OrderBy = @( + "FailedDeviceCount desc" + ) + } + Invoke-MgGraphRequest -Body $params -Uri "https://graph.microsoft.com/beta/deviceManagement/reports/getAppsInstallSummaryReport" -Method POST -OutputFilePath $Data + + $DataFile = Get-Content $Data + # Fix char encoding to UTF-8 + $Response = [system.Text.Encoding]::UTF8.GetString(($DataFile).ToCharArray()) | ConvertFrom-Json + + # Pre-build app lookup (id -> app) so the per-row joins are O(1) instead of O(N) Where-Object scans. + $appLookup = @{} + foreach ($a in $apps) { if ($a.Id) { $appLookup[[string]$a.Id] = $a } } + + # First pass: materialise the schema-flattened rows that have a matching app, + # collecting unique ApplicationIds so we can batch-fetch their assignments. + $rows = [System.Collections.Generic.List[hashtable]]::new() + $expandIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($value in $Response.Values) { + $LineObject = @{ } + foreach ($prop in $Response.Schema) { + $LineObject[$prop.Column] = $value[$Response.Schema.IndexOf($prop)] + } + $appId = [string]$LineObject.ApplicationId + if ($appLookup.ContainsKey($appId)) { + $rows.Add($LineObject) + [void]$expandIds.Add($appId) + } + } + + # Batch-fetch ?$expand=assignments for every failed app in parallel sub-requests. + $assignmentStatusByAppId = @{} + if ($expandIds.Count -gt 0) { + $expandRequests = foreach ($id in $expandIds) { + [PSCustomObject]@{ + Id = "app:$id" + Url = "/deviceAppManagement/mobileApps/$id/?`$expand=assignments" + } + } + $expandResponses = Invoke-RKGraphBatch -Requests @($expandRequests) -Activity 'App assignments' + foreach ($resp in $expandResponses) { + if ($resp.Status -ne 200 -or -not $resp.Body) { continue } + $appId = $resp.Id -replace '^app:', '' + $hasAssignments = $resp.Body.assignments -and (@($resp.Body.assignments).Count -gt 0) + $assignmentStatusByAppId[$appId] = [bool]$hasAssignments + } + } + + # Build result array from the prepared rows using O(1) lookups. + $ReturnObject = New-Object System.Collections.ArrayList + foreach ($LineObject in $rows) { + $appId = [string]$LineObject.ApplicationId + $PlatformName = if ($LineObject.Platform_loc) { $LineObject.Platform_loc } else { $LineObject.Platform } + $AssignmentStatus = $false + if ($assignmentStatusByAppId.ContainsKey($appId)) { $AssignmentStatus = $assignmentStatusByAppId[$appId] } + + $ReturnObject.Add([PSCustomObject][ordered]@{ + Customer = $tenantname + Application = $appLookup[$appId].displayName + Platform = $PlatformName + Version = $LineObject.AppVersion + AssignmentStatus = $AssignmentStatus + FailedUserCount = $LineObject.FailedUserCount + FailedDeviceCount = $LineObject.FailedDeviceCount + FailedDevicePercentage = [math]::Round([double]$LineObject.FailedDevicePercentage, 2) + }) | Out-Null + } + + # Clean up temporary data file + if (Test-Path -Path $Data) { + Remove-Item -Path $Data -Force + } + + return $ReturnObject | Sort-Object -Property FailedDeviceCount -Descending +} + +function Get-AutopilotProfilesInformation { +(Invoke-GraphRequest -Uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/" -OutputType PSObject).value +} + +# ---------------------------------------------------------------------------- +# BitLocker key escrow & Windows LAPS backup discovery. +# Determines per-device whether a BitLocker policy and a Windows LAPS (Entra- +# backed) policy are actually applied (via assignment resolution incl. groups, +# all users/devices, and assignment filters), and cross-references with the +# recovery key / local credential directory endpoints to flag escrow gaps. +# Reuses Invoke-GraphRequestWithPaging, Get-DetailedPolicyAssignments, +# Test-IntuneFilter, and the $script:AllFilters cache established by +# IntuneEnrollmentFlows.ps1 - load order is enforced in RKSolutions.psm1. +# ---------------------------------------------------------------------------- + +function Get-BitLockerLapsAssignmentContext { + <# + Bulk-fetches all Settings Catalog and legacy device configuration policies that + target BitLocker or Windows LAPS, resolves their assignments, fetches assignment + filters, primes group transitive member sets for groups referenced by those + assignments, and pulls the BitLocker recovery key and LAPS local credential + directories. Returns a single context object consumed by the per-device evaluators. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] [switch] $DebugMode + ) + + $context = [PSCustomObject]@{ + BitLockerPolicies = [System.Collections.Generic.List[object]]::new() + LapsPolicies = [System.Collections.Generic.List[object]]::new() + GroupDeviceAadIds = @{} # groupId -> HashSet of azureAdDeviceId + GroupUserUpns = @{} # groupId -> HashSet of lowercased UPN + GroupUserIds = @{} # groupId -> HashSet of user object id + BitLockerKeyDeviceIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + # Both LAPS deviceLocalCredentialInfo.id and bitlockerRecoveryKey.deviceId are + # the Azure AD device id (= managedDevice.azureADDeviceId). Verified against + # live Graph data. Both joins go through that single GUID. + LapsCredentialByDeviceId = @{} # azureAdDeviceId -> LAPS credential record + LapsCredentialByDeviceName = @{} # lowercased deviceName -> LAPS credential record (fallback join) + # Safety-net for device records that lack azureADDeviceId in Intune: lookup + # the Entra device by displayName, recover its deviceId, retry the join. + EntraDeviceIdByName = @{} # lowercased deviceName -> azureAdDeviceId + Filters = @{} # filterId -> filter object (mirror of $script:AllFilters) + # Raw configurationPolicies?$expand=settings result, retained so the + # deprecation walker can iterate every Settings Catalog policy without + # a second Graph round-trip. + ConfigurationPolicies = @() + } + + # 1. Prime assignment filters cache (used by Get-DetailedPolicyAssignments + Test-IntuneFilter). + if ($script:AllFilters.Count -eq 0) { + try { + $rawFilters = Invoke-GraphRequestWithPaging -Uri 'https://graph.microsoft.com/beta/deviceManagement/assignmentFilters' + if ($rawFilters) { foreach ($f in $rawFilters) { $script:AllFilters[$f.id] = $f } } + } + catch { Write-Verbose "Failed to load assignment filters: $($_.Exception.Message)" } + } + $context.Filters = $script:AllFilters + + # 2. Discover Settings Catalog policies that contain BitLocker or LAPS settings. + # Two-step pattern: list policies (small payload, fits in 1-2 pages) then $batch-fetch + # /settings per policy. Avoids Graph hard-capping page size when $expand=settings is used + # (observed ~1-2 policies per page on cosmos-backed tenants); batch sub-requests execute + # server-side in parallel, so ~75 policies finish in ~4 batched round trips. + Write-Host ' Discovering Settings Catalog policies (BitLocker / LAPS)...' -ForegroundColor Cyan + $configPolicies = @() + $swCfg = [System.Diagnostics.Stopwatch]::StartNew() + try { + $configPolicies = Invoke-GraphRequestWithPaging -Uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?$top=999' + } + catch { Write-Warning "configurationPolicies list fetch failed: $($_.Exception.Message)" } + $swCfg.Stop() + Write-Verbose ("[BitLockerLaps] configurationPolicies list: {0:N2}s ({1} policies)" -f $swCfg.Elapsed.TotalSeconds, $configPolicies.Count) + + if ($configPolicies.Count -gt 0) { + $swSet = [System.Diagnostics.Stopwatch]::StartNew() + $settingsRequests = foreach ($p in $configPolicies) { + [PSCustomObject]@{ + Id = "cps:$($p.id)" + Url = "/deviceManagement/configurationPolicies/$($p.id)/settings" + } + } + $settingsResponses = Invoke-RKGraphBatch -Requests @($settingsRequests) -Activity 'Settings Catalog policy settings' + $settingsByPolicyId = @{} + foreach ($resp in $settingsResponses) { + if ($resp.Status -ne 200 -or -not $resp.Body) { continue } + $pid = $resp.Id -replace '^cps:', '' + $settingsByPolicyId[$pid] = @($resp.Body.value) + } + # Attach settings back onto each policy so the existing loop below stays unchanged. + foreach ($p in $configPolicies) { + $s = $null + if ($settingsByPolicyId.ContainsKey($p.id)) { $s = $settingsByPolicyId[$p.id] } + if ($p.PSObject.Properties['settings']) { $p.settings = $s } + else { $p | Add-Member -NotePropertyName settings -NotePropertyValue $s -Force } + } + $swSet.Stop() + Write-Verbose ("[BitLockerLaps] Settings batch-fetch: {0:N2}s ({1} policies)" -f $swSet.Elapsed.TotalSeconds, $configPolicies.Count) + } + $context.ConfigurationPolicies = $configPolicies + + foreach ($policy in $configPolicies) { + if (-not $policy.settings) { continue } + $hasBitLocker = $false; $hasLaps = $false; $lapsBackupValue = $null + foreach ($s in $policy.settings) { + $defId = $null + if ($s.settingInstance -and $s.settingInstance.settingDefinitionId) { $defId = [string]$s.settingInstance.settingDefinitionId } + if (-not $defId) { continue } + $defIdLower = $defId.ToLowerInvariant() + if ($defIdLower.StartsWith($script:BitLockerSettingPrefix)) { $hasBitLocker = $true } + if ($defIdLower.StartsWith($script:LapsSettingPrefix)) { $hasLaps = $true } + if ($defIdLower -eq $script:LapsBackupDirectorySetting) { + # Choice settings expose the chosen value via choiceSettingValue.value, suffixed with the choice integer. + $val = $null + if ($s.settingInstance.choiceSettingValue -and $s.settingInstance.choiceSettingValue.value) { + $val = [string]$s.settingInstance.choiceSettingValue.value + } + if ($val) { + if ($val -match '_1$') { $lapsBackupValue = 1 } # Azure AD (Entra) + elseif ($val -match '_2$') { $lapsBackupValue = 2 } # On-prem AD + elseif ($val -match '_0$') { $lapsBackupValue = 0 } # Disabled + } + } + } + $displayName = if ($policy.name) { $policy.name } else { $policy.displayName } + if ($hasBitLocker) { + $assignments = Get-DetailedPolicyAssignments -EntityType 'configurationPolicies' -EntityId $policy.id -PolicyName $displayName -DebugMode:$DebugMode + $context.BitLockerPolicies.Add([PSCustomObject]@{ + Id = $policy.id + DisplayName = $displayName + Source = 'SettingsCatalog' + Assignments = @($assignments) + }) + } + if ($hasLaps -and $lapsBackupValue -eq 1) { + # Only Entra-backed LAPS policies are relevant to "can we read it from Entra?" + $assignments = Get-DetailedPolicyAssignments -EntityType 'configurationPolicies' -EntityId $policy.id -PolicyName $displayName -DebugMode:$DebugMode + $context.LapsPolicies.Add([PSCustomObject]@{ + Id = $policy.id + DisplayName = $displayName + Source = 'SettingsCatalog' + BackupDirectory = $lapsBackupValue + Assignments = @($assignments) + }) + } + } + + # 3. Discover legacy Endpoint Protection device configurations that include BitLocker settings. + Write-Host ' Discovering legacy device configuration policies (BitLocker)...' -ForegroundColor Cyan + try { + $deviceConfigs = Invoke-GraphRequestWithPaging -Uri 'https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations' + foreach ($cfg in $deviceConfigs) { + $odataType = [string]$cfg.'@odata.type' + if ($odataType -eq '#microsoft.graph.windows10EndpointProtectionConfiguration') { + $assignments = Get-DetailedPolicyAssignments -EntityType 'deviceConfigurations' -EntityId $cfg.id -PolicyName $cfg.displayName -DebugMode:$DebugMode + $context.BitLockerPolicies.Add([PSCustomObject]@{ + Id = $cfg.id + DisplayName = $cfg.displayName + Source = 'DeviceConfiguration' + Assignments = @($assignments) + }) + } + } + } + catch { Write-Warning "deviceConfigurations fetch failed: $($_.Exception.Message)" } + + # 4. Discover Endpoint Security intents (BitLocker disk encryption template). + Write-Host ' Discovering Endpoint Security intents (BitLocker)...' -ForegroundColor Cyan + try { + $intents = Invoke-GraphRequestWithPaging -Uri 'https://graph.microsoft.com/beta/deviceManagement/intents' + foreach ($intent in $intents) { + $name = [string]$intent.displayName + # No reliable filter for BitLocker template; match by name as a pragmatic discovery hint. + if ($name -match '(?i)bitlocker' -or $name -match '(?i)disk\s*encryption') { + $assignments = Get-DetailedPolicyAssignments -EntityType 'deviceManagement/intents' -EntityId $intent.id -PolicyName $name -DebugMode:$DebugMode + $context.BitLockerPolicies.Add([PSCustomObject]@{ + Id = $intent.id + DisplayName = $name + Source = 'Intent' + Assignments = @($assignments) + }) + } + } + } + catch { Write-Verbose "intents fetch skipped: $($_.Exception.Message)" } + + # 5. Collect every groupId referenced by any of those policies' assignments, then + # fetch transitive members for each group exactly once. + $referencedGroupIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($p in @($context.BitLockerPolicies + $context.LapsPolicies)) { + foreach ($a in $p.Assignments) { + if ($a.GroupId) { [void]$referencedGroupIds.Add([string]$a.GroupId) } + } + } + Write-Host " Resolving transitive members for $($referencedGroupIds.Count) group(s) used by BitLocker/LAPS assignments..." -ForegroundColor Cyan + # Initialise empty sets up front so every referenced group has entries even if a fetch fails. + foreach ($gid in $referencedGroupIds) { + $context.GroupDeviceAadIds[$gid] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $context.GroupUserUpns[$gid] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $context.GroupUserIds[$gid] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + } + if ($referencedGroupIds.Count -gt 0) { + $memberRequests = [System.Collections.Generic.List[object]]::new() + foreach ($gid in $referencedGroupIds) { + $memberRequests.Add([PSCustomObject]@{ + Id = "gd:$gid" + Url = "/groups/$gid/transitiveMembers/microsoft.graph.device?`$select=id,deviceId" + }) + $memberRequests.Add([PSCustomObject]@{ + Id = "gu:$gid" + Url = "/groups/$gid/transitiveMembers/microsoft.graph.user?`$select=id,userPrincipalName" + }) + } + # transitiveMembers lives on v1.0; route the batch there to keep sub-request URLs consistent. + $memberResponses = Invoke-RKGraphBatch -Requests @($memberRequests) -GraphVersion 'v1.0' -Activity 'Group transitive members' + # Any sub-response carrying @odata.nextLink overflows the first page (999 members). + # Fall back to sequential paging for those groups only — rare in practice. + $nextLinkFollowups = [System.Collections.Generic.List[object]]::new() + foreach ($resp in $memberResponses) { + if ($resp.Status -ne 200 -or -not $resp.Body) { continue } + $isUser = $resp.Id.StartsWith('gu:') + $gid = $resp.Id.Substring(3) + foreach ($m in @($resp.Body.value)) { + if ($isUser) { + if ($m.id) { [void]$context.GroupUserIds[$gid].Add([string]$m.id) } + if ($m.userPrincipalName) { [void]$context.GroupUserUpns[$gid].Add(([string]$m.userPrincipalName).ToLowerInvariant()) } + } else { + if ($m.deviceId) { [void]$context.GroupDeviceAadIds[$gid].Add([string]$m.deviceId) } + } + } + if ($resp.Body.'@odata.nextLink') { + $nextLinkFollowups.Add([PSCustomObject]@{ Gid = $gid; IsUser = $isUser; NextUri = [string]$resp.Body.'@odata.nextLink' }) + } + } + foreach ($f in $nextLinkFollowups) { + try { + $rest = Invoke-GraphRequestWithPaging -Uri $f.NextUri + foreach ($m in $rest) { + if ($f.IsUser) { + if ($m.id) { [void]$context.GroupUserIds[$f.Gid].Add([string]$m.id) } + if ($m.userPrincipalName) { [void]$context.GroupUserUpns[$f.Gid].Add(([string]$m.userPrincipalName).ToLowerInvariant()) } + } else { + if ($m.deviceId) { [void]$context.GroupDeviceAadIds[$f.Gid].Add([string]$m.deviceId) } + } + } + } + catch { Write-Verbose "Group $($f.Gid) nextLink follow-up failed: $($_.Exception.Message)" } + } + } + + # 6. BitLocker recovery keys. OS-volume key (volumeType == 1) is what protects the system drive. + # Use Invoke-MgGraphRequest directly (not the swallow-on-error paging helper) so a + # missing scope surfaces as a clear warning rather than an empty result set. + Write-Host ' Fetching BitLocker recovery key index (OS volume)...' -ForegroundColor Cyan + $bitLockerFetched = 0 + try { + $bitlockerUri = 'https://graph.microsoft.com/v1.0/informationProtection/bitlocker/recoveryKeys?$select=id,deviceId,volumeType,createdDateTime' + do { + $resp = Invoke-MgGraphRequest -Uri $bitlockerUri -Method GET -OutputType PSObject -ErrorAction Stop + if ($resp.value) { + foreach ($k in $resp.value) { + $bitLockerFetched++ + if ([int]$k.volumeType -eq 1 -and $k.deviceId) { [void]$context.BitLockerKeyDeviceIds.Add([string]$k.deviceId) } + } + } + $bitlockerUri = $resp.'@odata.nextLink' + } while ($bitlockerUri) + Write-Host " Indexed $($context.BitLockerKeyDeviceIds.Count) device(s) with OS-volume BitLocker keys (from $bitLockerFetched key rotations)." -ForegroundColor Green + } + catch { + $msg = $_.Exception.Message + if ($msg -match '403|Forbidden|Authorization|scopes') { + Write-Warning "BitLocker recoveryKeys fetch DENIED. Most likely cause: the connected app is missing BitlockerKey.ReadBasic.All. Without it, every encrypted device will be flagged 'no key in Entra'. Reconnect with: Disconnect-RKGraph; Connect-RKGraph" + } + else { Write-Warning "BitLocker recoveryKeys fetch failed: $msg" } + } + + # 7. LAPS local credentials directory. Entry's `id` field IS the Azure AD device id + # (verified against live Graph). Key the hashtable by it directly. + Write-Host ' Fetching directory/deviceLocalCredentials (Windows LAPS)...' -ForegroundColor Cyan + $lapsFetched = 0 + try { + $lapsUri = 'https://graph.microsoft.com/v1.0/directory/deviceLocalCredentials?$select=id,deviceName,lastBackupDateTime,refreshDateTime' + do { + $resp = Invoke-MgGraphRequest -Uri $lapsUri -Method GET -OutputType PSObject -ErrorAction Stop + if ($resp.value) { + foreach ($e in $resp.value) { + $lapsFetched++ + if ($e.id) { $context.LapsCredentialByDeviceId[[string]$e.id] = $e } + if ($e.deviceName) { + $context.LapsCredentialByDeviceName[([string]$e.deviceName).ToLowerInvariant()] = $e + } + } + } + $lapsUri = $resp.'@odata.nextLink' + } while ($lapsUri) + Write-Host " Indexed $lapsFetched LAPS local credential record(s)." -ForegroundColor Green + } + catch { + $msg = $_.Exception.Message + if ($msg -match '403|Forbidden|Authorization|scopes') { + Write-Warning "deviceLocalCredentials fetch DENIED. Most likely cause: the connected app is missing DeviceLocalCredential.ReadBasic.All. Without it, every device will be flagged 'no LAPS backup'. Reconnect with: Disconnect-RKGraph; Connect-RKGraph" + } + else { Write-Warning "deviceLocalCredentials fetch failed: $msg" } + } + + # 8. Safety-net deviceName -> deviceId map. Only used when a managedDevice record + # has no azureADDeviceId of its own (rare, but seen on partially-enrolled devices). + Write-Host ' Building Entra device name -> deviceId safety map...' -ForegroundColor Cyan + try { + $entraDevices = Invoke-GraphRequestWithPaging -Uri 'https://graph.microsoft.com/v1.0/devices?$select=deviceId,displayName' + foreach ($ed in $entraDevices) { + if ($ed.deviceId -and $ed.displayName) { + $context.EntraDeviceIdByName[([string]$ed.displayName).ToLowerInvariant()] = [string]$ed.deviceId + } + } + Write-Host " Mapped $($context.EntraDeviceIdByName.Count) Entra device(s) by displayName." -ForegroundColor Green + } + catch { Write-Warning "/v1.0/devices fetch failed: $($_.Exception.Message)" } + + return $context +} diff --git a/module/Core/IntuneAnomalyResolvers.ps1 b/module/Core/IntuneAnomalyResolvers.ps1 new file mode 100644 index 0000000..26076cc --- /dev/null +++ b/module/Core/IntuneAnomalyResolvers.ps1 @@ -0,0 +1,478 @@ +function ConvertTo-ComplianceSettingFriendlyName { + param([string] $SettingName) + if ($SettingName -match '^(Windows10CompliancePolicy|DefaultDeviceCompliancePolicy)\.(.+)$' -and $script:ComplianceSettingFriendlyNames.ContainsKey($Matches[2])) { + return $script:ComplianceSettingFriendlyNames[$Matches[2]] + } + return $SettingName +} + +function Resolve-DeviceAzureAdDeviceId { + <# + Return the Azure AD device id to use for joining with BitLocker recovery keys + and LAPS credentials. Prefer the value already on the device record; fall back + to the deviceName -> deviceId map populated by Get-BitLockerLapsAssignmentContext. + #> + param([Parameter(Mandatory)] [PSCustomObject] $Device, [Parameter(Mandatory)] [PSCustomObject] $Context) + if ($Device.AzureAdDeviceId) { return [string]$Device.AzureAdDeviceId } + if ($Device.DeviceName) { + $key = ([string]$Device.DeviceName).ToLowerInvariant() + if ($Context.EntraDeviceIdByName.ContainsKey($key)) { return $Context.EntraDeviceIdByName[$key] } + } + return $null +} + +function ConvertTo-FilterDeviceProperties { + <# + Map a Get-AllDeviceData record onto the property names that Test-IntuneFilter + recognises (deviceName, operatingSystem, osVersion, manufacturer, model, + isEncrypted, ownerType, serialNumber, enrollmentProfileName, complianceState). + #> + param([Parameter(Mandatory)] [PSCustomObject] $Device) + [PSCustomObject]@{ + DeviceName = $Device.DeviceName + OperatingSystem = $Device.Operatingsystem + OSVersion = $Device.OperatingSystemVersion + Manufacturer = $Device.DeviceManufacturer + Model = $Device.DeviceModel + IsEncrypted = $Device.Encrypted + OwnerType = $Device.DeviceOwnership + SerialNumber = $Device.Serialnumber + EnrollmentProfileName = $Device.EnrollmentProfile + ComplianceState = $Device.ComplianceStatus + ProcessorArchitecture = $Device.ProcessorArchitecture + DeviceType = 'desktop' + UserPrincipalName = $Device.PrimaryUser + AzureAdDeviceId = $Device.AzureAdDeviceId + } +} + +function Test-AssignmentAppliesToManagedDevice { + <# + Evaluates a single Get-DetailedPolicyAssignments row against a managed device + record using the pre-resolved group membership sets in the context. Returns + @{ Applies = $bool; FilterDecision = '...' }. Assignment filters are honored + via Test-IntuneFilter (provided by IntuneEnrollmentFlows.ps1). + #> + param( + [Parameter(Mandatory)] [PSCustomObject] $Assignment, + [Parameter(Mandatory)] [PSCustomObject] $Device, + [Parameter(Mandatory)] [PSCustomObject] $Context + ) + + $aadDeviceId = if ($Device.AzureAdDeviceId) { [string]$Device.AzureAdDeviceId } else { '' } + $primaryUpn = if ($Device.PrimaryUser) { ([string]$Device.PrimaryUser).ToLowerInvariant() } else { '' } + $baseApplies = $false + + switch ($Assignment.AssignmentType) { + 'All Devices' { $baseApplies = $true } + 'All Users' { $baseApplies = -not [string]::IsNullOrEmpty($primaryUpn) } + 'Group (Include)' { + if ($Assignment.GroupId) { + $gid = [string]$Assignment.GroupId + $inDeviceGroup = $false; $inUserGroup = $false + if ($Context.GroupDeviceAadIds.ContainsKey($gid) -and $aadDeviceId) { + $inDeviceGroup = $Context.GroupDeviceAadIds[$gid].Contains($aadDeviceId) + } + if ($Context.GroupUserUpns.ContainsKey($gid) -and $primaryUpn) { + $inUserGroup = $Context.GroupUserUpns[$gid].Contains($primaryUpn) + } + $baseApplies = $inDeviceGroup -or $inUserGroup + } + } + 'Group (Exclude)' { $baseApplies = $false } # Handled explicitly by caller as an exclusion signal. + default { $baseApplies = $false } + } + + if (-not $baseApplies) { return [PSCustomObject]@{ Applies = $false; FilterDecision = 'N/A' } } + + # Assignment filter handling. Filter rules use property names like deviceName, + # operatingSystem, manufacturer, model, osVersion - all surfaced by Test-IntuneFilter. + $filterDecision = 'N/A' + $hasFilter = $Assignment.FilterId -and $Assignment.FilterId -ne '00000000-0000-0000-0000-000000000000' ` + -and $Assignment.FilterType -and $Assignment.FilterType -ne 'None' -and $Assignment.FilterType -ne 'none' + if ($hasFilter) { + $filter = $Context.Filters[$Assignment.FilterId] + if ($filter -and $filter.rule) { + $filterDeviceProps = ConvertTo-FilterDeviceProperties -Device $Device + $matched = Test-IntuneFilter -FilterRule $filter.rule -DeviceProperties $filterDeviceProps + $filterDecision = if ($matched) { 'Matched' } else { 'NotMatched' } + $ft = ([string]$Assignment.FilterType).ToLowerInvariant() + if ($ft -eq 'include') { if (-not $matched) { return [PSCustomObject]@{ Applies = $false; FilterDecision = $filterDecision } } } + elseif ($ft -eq 'exclude') { if ($matched) { return [PSCustomObject]@{ Applies = $false; FilterDecision = $filterDecision } } } + } + else { $filterDecision = 'FilterNotFound' } + } + + return [PSCustomObject]@{ Applies = $true; FilterDecision = $filterDecision } +} + +function Get-DeviceAppliedPolicies { + <# + Walks a list of policies (each carrying its full assignment set) and decides, + for the given device, whether each policy: + - applies -> goes in .Applied + - was explicitly excluded -> goes in .Excluded with a reason + ('ExcludeGroup' | 'AssignmentFilter') + - simply doesn't target it -> ignored (not anomalous in itself) + + A policy that targeted the device's group/user but was then rejected by an + assignment filter, or by an exclusion group, is an *intentional* exclusion - + callers can hide those from anomaly output by default and surface them under + an opt-in flag. + #> + param( + [Parameter(Mandatory)] [array] $Policies, + [Parameter(Mandatory)] [PSCustomObject] $Device, + [Parameter(Mandatory)] [PSCustomObject] $Context + ) + + $applied = [System.Collections.Generic.List[object]]::new() + $excluded = [System.Collections.Generic.List[object]]::new() + $aadDeviceId = if ($Device.AzureAdDeviceId) { [string]$Device.AzureAdDeviceId } else { '' } + $primaryUpn = if ($Device.PrimaryUser) { ([string]$Device.PrimaryUser).ToLowerInvariant() } else { '' } + + foreach ($policy in $Policies) { + # First pass: any exclude group that names this device's identity wins + # outright, regardless of include scope. + $excludeGroupHit = $false + foreach ($a in $policy.Assignments) { + if ($a.AssignmentType -ne 'Group (Exclude)' -or -not $a.GroupId) { continue } + $gid = [string]$a.GroupId + if ($aadDeviceId -and $Context.GroupDeviceAadIds.ContainsKey($gid) -and $Context.GroupDeviceAadIds[$gid].Contains($aadDeviceId)) { $excludeGroupHit = $true; break } + if ($primaryUpn -and $Context.GroupUserUpns.ContainsKey($gid) -and $Context.GroupUserUpns[$gid].Contains($primaryUpn)) { $excludeGroupHit = $true; break } + } + if ($excludeGroupHit) { + $excluded.Add([PSCustomObject]@{ PolicyName = $policy.DisplayName; Source = $policy.Source; Reason = 'ExcludeGroup' }) + continue + } + + # Second pass: walk include-style assignments. Track whether any base-level + # match happened (device was *targeted*) so we can distinguish a filter + # rejection ("intentional exclusion") from a plain non-match ("never in scope"). + $includeMatch = $null + $baseTargetedButFiltered = $false + foreach ($a in $policy.Assignments) { + if ($a.AssignmentType -eq 'Group (Exclude)' -or $a.AssignmentType -eq 'Not Assigned') { continue } + $result = Test-AssignmentAppliesToManagedDevice -Assignment $a -Device $Device -Context $Context + if ($result.Applies) { $includeMatch = $result; break } + # Applies=$false with a non-N/A FilterDecision == base matched, filter rejected. + if ($result.FilterDecision -in 'Matched','NotMatched','FilterNotFound') { $baseTargetedButFiltered = $true } + } + if ($includeMatch) { + $applied.Add([PSCustomObject]@{ PolicyName = $policy.DisplayName; Source = $policy.Source; FilterDecision = $includeMatch.FilterDecision }) + } + elseif ($baseTargetedButFiltered) { + $excluded.Add([PSCustomObject]@{ PolicyName = $policy.DisplayName; Source = $policy.Source; Reason = 'AssignmentFilter' }) + } + # else: policy didn't target this device at all. Not surfaced. + } + + return [PSCustomObject]@{ Applied = @($applied); Excluded = @($excluded) } +} + +function Resolve-IntuneBitLockerAnomalies { + <# + For each Windows managed device, decide whether BitLocker is governed by an + assigned Intune policy and whether the OS-volume recovery key is escrowed to + Entra. Emits one row per anomalous device with a severity bucket. + + A device that was deliberately excluded (via an exclude group or an assignment + filter that rejected it) is *not* an anomaly by default - that's the admin's + explicit intent. Pass -ShowExcludedDevices to surface those rows as Info. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [array] $Devices, + [Parameter(Mandatory)] [PSCustomObject] $Context, + [Parameter(Mandatory)] [string] $TenantName, + [switch] $ShowExcludedDevices + ) + $out = [System.Collections.Generic.List[PSObject]]::new() + foreach ($d in $Devices) { + $aadId = Resolve-DeviceAzureAdDeviceId -Device $d -Context $Context + $isEncrypted = [bool]$d.Encrypted + + # No Entra device id: we can't evaluate policy assignment or key escrow + # for this device, but we still flag "not encrypted" since that's the + # one anomaly we can detect from the managed-device record alone. + if (-not $aadId) { + if (-not $isEncrypted) { + $out.Add([PSCustomObject][ordered]@{ + Customer = $TenantName + DeviceName = $d.DeviceName + PrimaryUser = $d.PrimaryUser + Serialnumber = $d.Serialnumber + IsEncrypted = 'No' + AppliedPolicies = '' + KeyEscrowed = 'Unknown' + Severity = 'Warning' + Status = 'Device not encrypted (no Azure AD device id to verify policy / key state)' + }) + } + continue + } + + $eval = Get-DeviceAppliedPolicies -Policies $Context.BitLockerPolicies -Device $d -Context $Context + $applied = $eval.Applied + $excluded = $eval.Excluded + $hasKey = $Context.BitLockerKeyDeviceIds.Contains($aadId) + + $status = $null; $severity = $null + if ($applied.Count -eq 0 -and $excluded.Count -gt 0) { + # Intentional exclusion. Skip unless caller asked to surface them. + if (-not $ShowExcludedDevices) { continue } + $reasons = ($excluded | Select-Object -ExpandProperty Reason -Unique) -join ', ' + $status = "Intentionally excluded from BitLocker policies ($reasons)" + $severity = 'Info' + } + elseif (-not $isEncrypted -and $applied.Count -eq 0) { $status = 'Device not encrypted and no BitLocker policy assigned'; $severity = 'Critical' } + elseif ($applied.Count -eq 0) { $status = 'No BitLocker policy assigned'; $severity = 'Warning' } + elseif ($isEncrypted -and -not $hasKey) { $status = 'Encrypted but no OS-volume key in Entra'; $severity = 'Critical' } + elseif (-not $isEncrypted) { $status = 'BitLocker policy assigned but device not encrypted'; $severity = 'Critical' } + else { continue } # Healthy. + + $out.Add([PSCustomObject][ordered]@{ + Customer = $TenantName + DeviceName = $d.DeviceName + PrimaryUser = $d.PrimaryUser + Serialnumber = $d.Serialnumber + IsEncrypted = if ($isEncrypted) { 'Yes' } else { 'No' } + AppliedPolicies = if ($applied.Count -gt 0) { ($applied | ForEach-Object { $_.PolicyName }) -join '; ' } else { ($excluded | ForEach-Object { "$($_.PolicyName) [$($_.Reason)]" }) -join '; ' } + KeyEscrowed = if ($hasKey) { 'Yes' } else { 'No' } + Severity = $severity + Status = $status + }) + } + return $out +} + +function Resolve-IntuneLapsAnomalies { + <# + For each Windows managed device, decide whether an Entra-backed Windows LAPS + policy is assigned and whether a local admin credential is actually backed up. + A backup older than -MaxBackupAgeDays is reported as a rotation gap. + + Devices that were explicitly excluded (exclude group or assignment filter) are + suppressed by default; pass -ShowExcludedDevices to surface them as Info rows. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [array] $Devices, + [Parameter(Mandatory)] [PSCustomObject] $Context, + [Parameter(Mandatory)] [string] $TenantName, + [int] $MaxBackupAgeDays = 60, + [switch] $ShowExcludedDevices + ) + $out = [System.Collections.Generic.List[PSObject]]::new() + $cutoff = (Get-Date).AddDays(-$MaxBackupAgeDays) + + foreach ($d in $Devices) { + $aadId = Resolve-DeviceAzureAdDeviceId -Device $d -Context $Context + if (-not $aadId) { continue } + $eval = Get-DeviceAppliedPolicies -Policies $Context.LapsPolicies -Device $d -Context $Context + $applied = $eval.Applied + $excluded = $eval.Excluded + + # Primary join: LAPS credentials are keyed by the Azure AD device id. + # Secondary join (deviceName) covers the rare case where the device id we + # have differs from the one Entra stored the LAPS entry against. + $entry = $Context.LapsCredentialByDeviceId[$aadId] + if (-not $entry -and $d.DeviceName) { + $nameKey = ([string]$d.DeviceName).ToLowerInvariant() + if ($Context.LapsCredentialByDeviceName.ContainsKey($nameKey)) { + $entry = $Context.LapsCredentialByDeviceName[$nameKey] + } + } + + $status = $null; $severity = $null + $lastBackup = $null; $ageDays = $null + if ($entry -and $entry.lastBackupDateTime) { + try { $lastBackup = [datetime]$entry.lastBackupDateTime; $ageDays = [int](((Get-Date) - $lastBackup).TotalDays) } catch { } + } + + if ($applied.Count -eq 0 -and $excluded.Count -gt 0) { + if (-not $ShowExcludedDevices) { continue } + $reasons = ($excluded | Select-Object -ExpandProperty Reason -Unique) -join ', ' + $status = "Intentionally excluded from LAPS policies ($reasons)" + $severity = 'Info' + } + elseif ($applied.Count -eq 0) { $status = 'No Entra-backed LAPS policy assigned'; $severity = 'Warning' } + elseif (-not $entry) { $status = 'LAPS policy assigned but no credential backed up to Entra'; $severity = 'Critical' } + elseif ($lastBackup -and $lastBackup -lt $cutoff) { $status = "LAPS backup stale (> $MaxBackupAgeDays days, rotation may be stalled)"; $severity = 'Warning' } + else { continue } # Healthy. + + $out.Add([PSCustomObject][ordered]@{ + Customer = $TenantName + DeviceName = $d.DeviceName + PrimaryUser = $d.PrimaryUser + Serialnumber = $d.Serialnumber + OwnerType = $d.DeviceOwnership + AppliedPolicies = if ($applied.Count -gt 0) { ($applied | ForEach-Object { $_.PolicyName }) -join '; ' } else { ($excluded | ForEach-Object { "$($_.PolicyName) [$($_.Reason)]" }) -join '; ' } + LastBackupDateTime = if ($lastBackup) { $lastBackup.ToString('yyyy-MM-dd HH:mm') } else { '' } + BackupAgeDays = if ($null -ne $ageDays) { $ageDays } else { '' } + Severity = $severity + Status = $status + }) + } + return $out +} + +# ---------------------------------------------------------------------------- +# Deprecated Intune settings discovery. +# Walks every Settings Catalog policy already fetched into the context by +# Get-BitLockerLapsAssignmentContext and flags individual settings that +# Microsoft has marked as deprecated. Detection sources, in order of precedence: +# 1. Catalog DisplayName for the settingDefinitionId contains "deprecated" +# 2. The setting's raw definition id contains "deprecated" +# 3. The configured value string contains "deprecated" +# Catalog data comes from Get-RKIntuneSettingsCatalog (cached download from +# github.com/royklo/IntuneSettingsCatalogData); when the catalog is empty the +# walker still detects the subset that carries "deprecated" in the raw id or +# value string. +# ---------------------------------------------------------------------------- + +function Test-IsSettingDeprecated { + <# + Returns @{ IsDeprecated; Source; DisplayName } where Source is one of + 'CatalogDisplayName' | 'DefinitionIdMatch' | 'ValueMatch' on a hit. + #> + [CmdletBinding()] + param( + [string] $DefinitionId, + [string] $Value, + [hashtable]$Catalog + ) + + if ($Catalog -and $Catalog.Count -gt 0 -and -not [string]::IsNullOrEmpty($DefinitionId) -and $Catalog.ContainsKey($DefinitionId)) { + $dn = [string]$Catalog[$DefinitionId].DisplayName + if ($dn -match '(?i)deprecated') { + return [PSCustomObject]@{ IsDeprecated = $true; Source = 'CatalogDisplayName'; DisplayName = $dn } + } + } + if (-not [string]::IsNullOrEmpty($DefinitionId) -and $DefinitionId -match '(?i)deprecated') { + $dn = if ($Catalog -and $Catalog.ContainsKey($DefinitionId)) { [string]$Catalog[$DefinitionId].DisplayName } else { $DefinitionId } + return [PSCustomObject]@{ IsDeprecated = $true; Source = 'DefinitionIdMatch'; DisplayName = $dn } + } + if (-not [string]::IsNullOrEmpty($Value) -and $Value -match '(?i)deprecated') { + $dn = if ($Catalog -and $Catalog.ContainsKey($DefinitionId)) { [string]$Catalog[$DefinitionId].DisplayName } else { $DefinitionId } + return [PSCustomObject]@{ IsDeprecated = $true; Source = 'ValueMatch'; DisplayName = $dn } + } + return [PSCustomObject]@{ IsDeprecated = $false } +} + +function Get-IntuneSettingValueString { + <# + Extract the configured value(s) from a settingInstance, regardless of OData + sub-type (choice / simple / collection). Returns a string suitable for + display and for the deprecation regex check. Group / collection instances + return empty here; their children are walked separately by the caller. + #> + [CmdletBinding()] + [OutputType([string])] + param([Parameter(Mandatory)] $SettingInstance) + + $type = [string]$SettingInstance.'@odata.type' + switch -Wildcard ($type) { + '*choiceSettingInstance*' { return [string]$SettingInstance.choiceSettingValue.value } + '*simpleSettingInstance*' { return [string]$SettingInstance.simpleSettingValue.value } + '*choiceSettingCollectionInstance*' { + $vals = @() + foreach ($cv in @($SettingInstance.choiceSettingCollectionValue)) { if ($cv.value) { $vals += [string]$cv.value } } + return ($vals -join '; ') + } + '*simpleSettingCollectionInstance*' { + $vals = @() + foreach ($sv in @($SettingInstance.simpleSettingCollectionValue)) { if ($sv.value) { $vals += [string]$sv.value } } + return ($vals -join '; ') + } + default { return '' } + } +} + +function Get-IntuneSettingDeprecations { + <# + Recursively walk a settingInstance (and any nested group children) and + return one record per deprecated setting found: + { DefinitionId; DisplayName; Value; Source } + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] $SettingInstance, + [hashtable] $Catalog + ) + + $hits = [System.Collections.Generic.List[object]]::new() + if (-not $SettingInstance) { return $hits } + + $defId = [string]$SettingInstance.settingDefinitionId + $value = Get-IntuneSettingValueString -SettingInstance $SettingInstance + $check = Test-IsSettingDeprecated -DefinitionId $defId -Value $value -Catalog $Catalog + if ($check.IsDeprecated) { + $hits.Add([PSCustomObject]@{ + DefinitionId = $defId + DisplayName = if ([string]::IsNullOrWhiteSpace($check.DisplayName)) { $defId } else { $check.DisplayName } + Value = $value + Source = $check.Source + }) + } + + $type = [string]$SettingInstance.'@odata.type' + if ($type -like '*groupSettingCollectionInstance*') { + foreach ($child in @($SettingInstance.groupSettingCollectionValue)) { + foreach ($childSetting in @($child.children)) { + foreach ($h in (Get-IntuneSettingDeprecations -SettingInstance $childSetting -Catalog $Catalog)) { $hits.Add($h) } + } + } + } + elseif ($type -like '*groupSettingInstance*') { + foreach ($childSetting in @($SettingInstance.groupSettingValue.children)) { + foreach ($h in (Get-IntuneSettingDeprecations -SettingInstance $childSetting -Catalog $Catalog)) { $hits.Add($h) } + } + } + return $hits +} + +function Resolve-IntuneDeprecatedAnomalies { + <# + Iterates the Settings Catalog policies already on the context, scans + every setting against the cached settings-catalog lookup, and emits one + row per deprecated setting in use. Loads / refreshes the catalog on + first call; subsequent calls in the same session reuse it. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [PSCustomObject] $Context, + [Parameter(Mandatory)] [string] $TenantName + ) + + Write-Host ' Scanning Settings Catalog policies for deprecated settings...' -ForegroundColor Cyan + $catalog = Get-RKIntuneSettingsCatalog + $out = [System.Collections.Generic.List[PSObject]]::new() + + foreach ($policy in $Context.ConfigurationPolicies) { + if (-not $policy.settings) { continue } + $policyName = if ($policy.name) { $policy.name } elseif ($policy.displayName) { $policy.displayName } else { '(unnamed)' } + $platform = if ($policy.platforms) { [string]$policy.platforms } else { '' } + $technologies = if ($policy.technologies) { [string]$policy.technologies } else { '' } + + foreach ($s in @($policy.settings)) { + if (-not $s.settingInstance) { continue } + foreach ($hit in (Get-IntuneSettingDeprecations -SettingInstance $s.settingInstance -Catalog $catalog)) { + $out.Add([PSCustomObject]@{ + Customer = $TenantName + PolicyName = $policyName + PolicyId = $policy.id + Platform = $platform + Technologies = $technologies + SettingDisplayName = $hit.DisplayName + SettingDefinitionId = $hit.DefinitionId + ConfiguredValue = $hit.Value + DetectionSource = $hit.Source + }) + } + } + } + + Write-Host " Found $($out.Count) deprecated setting instance(s) across $($Context.ConfigurationPolicies.Count) Settings Catalog policies." -ForegroundColor Gray + return $out +} diff --git a/module/Private/IntuneAnomalies.ps1 b/module/Private/IntuneAnomalies.ps1 index 7ca3a03..0317eb7 100644 --- a/module/Private/IntuneAnomalies.ps1 +++ b/module/Private/IntuneAnomalies.ps1 @@ -1293,1334 +1293,3 @@ function New-IntuneAnomaliesHTMLReport { catch { Write-Host "Report saved to: $ExportPath (could not open automatically)." -ForegroundColor Yellow } } } - - -function ConvertTo-ComplianceSettingFriendlyName { - param([string] $SettingName) - if ($SettingName -match '^(Windows10CompliancePolicy|DefaultDeviceCompliancePolicy)\.(.+)$' -and $script:ComplianceSettingFriendlyNames.ContainsKey($Matches[2])) { - return $script:ComplianceSettingFriendlyNames[$Matches[2]] - } - return $SettingName -} - -function Get-AllDeviceData { - function Get-OperatingSystemProductType { - param ( - $Customer - ) - - @{ - "0" = "unknown" - "4" = "Windows 10/11 Enterprise" - "27" = "Windows 10/11 Enterprise N" - "48" = "Windows 10/11 Professional" - "49" = "Windows 10/11 Professional for workstation N" - "72" = "Windows 10/11 Enterprise Evaluation" - "119" = "Windows 10 TeamOS" - "121" = "Windows 10/11 Education" - "122" = "Windows 10/11 Education N" - "125" = "Windows 10 Enterprise LTSC" - "136" = "Hololens" - "175" = "Windows 10 / 11 Enterprise Multi-session" - }.$Customer - } - - function Get-OSFriendlyName { - param ( - [string]$OperatingSystemVersion - ) - - switch -Regex ($OperatingSystemVersion) { - "^10\.0\.19043" { return "Windows 10 21H1" } - "^10\.0\.19044" { return "Windows 10 21H2" } - "^10\.0\.19045" { return "Windows 10 22H2" } - "^10\.0\.22000" { return "Windows 11 21H2" } - "^10\.0\.22621" { return "Windows 11 22H2" } - "^10\.0\.22631" { return "Windows 11 23H2" } - "^10\.0\.22635" { return "Windows 11 23H2 Insider Preview" } - "^10\.0\.261" { return "Windows 11 24H2" } - "^10\.0\.262" { return "Windows 11 25H2" } - default { return "Other" } - } - } - - function Convert-Size { - [cmdletbinding()] - param( - [validateset("Bytes", "KB", "MB", "GB", "TB")] - [string]$From, - [validateset("Bytes", "KB", "MB", "GB", "TB")] - [string]$To, - [Parameter(Mandatory = $true)] - [double]$Value, - [int]$Precision = 4 - ) - switch ($From) { - "Bytes" { $value = $Value } - "KB" { $value = $Value * 1024 } - "MB" { $value = $Value * 1024 * 1024 } - "GB" { $value = $Value * 1024 * 1024 * 1024 } - "TB" { $value = $Value * 1024 * 1024 * 1024 * 1024 } - } - - switch ($To) { - "Bytes" { return $value } - "KB" { $Value = $Value / 1KB } - "MB" { $Value = $Value / 1MB } - "GB" { $Value = $Value / 1GB } - "TB" { $Value = $Value / 1TB } - - } - - $Calc = [Math]::Round($value, $Precision, [MidPointRounding]::AwayFromZero) - return "$calc $to" - - } - - # Optimized Properties List - Only essential properties for better performance - $Properties = @( - 'Id', # Required for compliance data fetching and unique identification - 'DeviceName', - 'azureADDeviceId', # Canonical casing - the join key for BitLocker keys + LAPS credentials - 'azureActiveDirectoryDeviceId', # Legacy fallback property; same GUID on modern tenants - 'ManagedDeviceOwnerType', - 'UserPrincipalName', # Primary user - 'SerialNumber', - 'ManagedDeviceName', - 'Manufacturer', - 'Model', - 'ProcessorArchitecture', - 'WiFiMacAddress', - 'EthernetMacAddress', - 'TotalStorageSpaceInBytes', - 'FreeStorageSpaceInBytes', - 'EnrolledDateTime', - 'LastSyncDateTime', - 'EnrollmentProfileName', - 'IsEncrypted', - 'DeviceEnrollmentType', - 'OperatingSystem', - 'OSVersion', - 'ComplianceState', - 'usersLoggedOn', # Contains userId for logged-on users - 'hardwareInformation', # Contains nested properties like tpmVersion, OS details, BiosVersion - 'managementAgent', # Indicates the management agent used (e.g., Intune) - 'skuFamily' # OS edition (Pro, Enterprise, Home, etc.) - more reliable than hardwareInformation.operatingSystemEdition - ) - - $swTotal = [System.Diagnostics.Stopwatch]::StartNew() - - # Get all Windows Devices from Microsoft Intune - $swStep = [System.Diagnostics.Stopwatch]::StartNew() - $AllDeviceData = Invoke-graphRequestWithPaging -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=operatingSystem eq 'Windows'&`$select=$($Properties -join ',')" - #filter out managed by MDE - $AllDeviceData = $AllDeviceData | Where-Object { $_.managementAgent -ne "msSense" } - $swStep.Stop() - Write-Verbose ("[Get-AllDeviceData] Managed devices fetch: {0:N2}s ({1} devices)" -f $swStep.Elapsed.TotalSeconds, $AllDeviceData.Count) - - # The list endpoint always reports deviceType 'windowsRT'; only a per-device GET returns 'cloudPC' - # (Windows 365 and Dev Box). A failed lookup counts as a physical device so nothing is hidden by mistake. - $swStep.Restart() - $CloudPcIds = [System.Collections.Generic.HashSet[string]]::new() - $deviceTypeRequests = foreach ($d in $AllDeviceData) { - [PSCustomObject]@{ Id = "dt:$($d.id)"; Url = "/deviceManagement/managedDevices/$($d.id)?`$select=id,deviceType" } - } - foreach ($resp in (Invoke-RKGraphBatch -Requests @($deviceTypeRequests) -Activity 'Device types')) { - if ($resp.Status -eq 200 -and $resp.Body.deviceType -eq 'cloudPC') { [void]$CloudPcIds.Add(($resp.Id -replace '^dt:', '')) } - } - $swStep.Stop() - Write-Verbose ("[Get-AllDeviceData] Device type lookup: {0:N2}s ({1} Cloud PCs)" -f $swStep.Elapsed.TotalSeconds, $CloudPcIds.Count) - - # Get all AutoPilot registered devices under "Enrollment" - Write-Host "Fetching Autopilot devices..." -ForegroundColor Yellow - $swStep.Restart() - $AutopilotDevices = (Invoke-GraphRequestWithPaging -Uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities") - $swStep.Stop() - Write-Verbose ("[Get-AllDeviceData] Autopilot devices fetch: {0:N2}s ({1} devices)" -f $swStep.Elapsed.TotalSeconds, $AutopilotDevices.Count) - - # Pre-build Autopilot lookup hashtable (serialNumber -> device object) for O(1) lookups - $AutopilotLookup = @{} - foreach ($ap in $AutopilotDevices) { - if ($ap.serialNumber) { $AutopilotLookup[$ap.serialNumber] = $ap } - } - - # Autopilot device preparation (v2) associates devices via corporate identifiers - # ("Manufacturer,Model,Serial" or a bare serial) instead of a hardware hash. - $CorporateIdentifierSerials = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - $CorporateIdentifiers = Invoke-GraphRequestWithPaging -Uri "https://graph.microsoft.com/beta/deviceManagement/importedDeviceIdentities" - foreach ($ci in $CorporateIdentifiers) { - if ($ci.importedDeviceIdentifier) { [void]$CorporateIdentifierSerials.Add(($ci.importedDeviceIdentifier -split ',')[-1].Trim()) } - } - - # Pre-build user lookup hashtable (id -> UPN) for O(1) lookups - $UserLookup = @{} - foreach ($u in $AllEntraIDUsers) { - if ($u.id -and $u.userPrincipalName) { $UserLookup[$u.id] = $u.userPrincipalName } - } - - # Pre-fetch compliance rules for all noncompliant devices via Graph $batch. - # Two batched passes replace 1+N per-device round trips: first the policy - # state list per device, then the settingStates per nonCompliant policy. - $ComplianceRulesByDevice = @{} - $NonCompliantDevices = $AllDeviceData | Where-Object { $_.complianceState -eq 'noncompliant' } - if ($NonCompliantDevices -and $NonCompliantDevices.Count -gt 0) { - # Module-scoped map: reset so a previous tenant's day count never leaks into this report. - $script:ComplianceSettingFriendlyNames['RequireRemainContact'] = 'Is active (no compliance check-in within the compliance status validity period)' - try { - $checkinDays = (Invoke-MgGraphRequest -Uri 'https://graph.microsoft.com/beta/deviceManagement/settings' -OutputType PSObject -ErrorAction Stop).deviceComplianceCheckinThresholdDays - if ($checkinDays) { $script:ComplianceSettingFriendlyNames['RequireRemainContact'] = "Is active (no compliance check-in in the last $checkinDays days)" } - } catch { - Write-Verbose "Compliance validity period lookup failed: $($_.Exception.Message)" - } - Write-Host "Fetching compliance details for $($NonCompliantDevices.Count) noncompliant devices..." -ForegroundColor Yellow - - $policyStateRequests = foreach ($d in $NonCompliantDevices) { - [PSCustomObject]@{ - Id = "ps:$($d.id)" - Url = "/deviceManagement/managedDevices/$($d.id)/deviceCompliancePolicyStates" - } - } - - $swStep.Restart() - $policyStateResponses = Invoke-RKGraphBatch -Requests @($policyStateRequests) -Activity "Compliance policy states" - $swStep.Stop() - Write-Verbose ("[Get-AllDeviceData] Compliance policy-states batch: {0:N2}s ({1} devices, {2} responses)" -f $swStep.Elapsed.TotalSeconds, $NonCompliantDevices.Count, $policyStateResponses.Count) - - # Map deviceId -> array of nonCompliant/Error policy state ids (preserving the existing <=10 guard). - # Graph occasionally returns the same policy-state id twice for a single device (e.g. when the - # same policy resolves via more than one assignment path). Adding both would produce duplicate - # sub-request ids in the batch, which Graph rejects with 400 "Request Id ... has to be unique - # in a batch." Dedupe the state list per device by id BEFORE the <=10 guard so duplicates - # don't accidentally push a device past the cap and silently drop its compliance details. - $settingStatePairs = [System.Collections.Generic.List[object]]::new() - foreach ($resp in $policyStateResponses) { - if ($resp.Status -ne 200 -or -not $resp.Body) { continue } - $deviceId = $resp.Id -replace '^ps:', '' - $rawStates = @($resp.Body.value | Where-Object { $_.State -eq 'nonCompliant' -or $_.State -eq 'Error' }) - - $seenStateIds = [System.Collections.Generic.HashSet[string]]::new() - $states = [System.Collections.Generic.List[object]]::new() - foreach ($s in $rawStates) { - if ($seenStateIds.Add($s.id)) { $states.Add($s) } - } - - if ($states.Count -eq 0 -or $states.Count -gt 10) { continue } - $ComplianceRulesByDevice[$deviceId] = [System.Collections.Generic.List[string]]::new() - foreach ($s in $states) { - $settingStatePairs.Add([PSCustomObject]@{ - Id = "ss:$deviceId|$($s.id)" - Url = "/deviceManagement/managedDevices/$deviceId/deviceCompliancePolicyStates/$($s.id)/settingStates" - }) - } - } - - if ($settingStatePairs.Count -gt 0) { - $swStep.Restart() - $settingResponses = Invoke-RKGraphBatch -Requests @($settingStatePairs) -Activity "Compliance setting states" - $swStep.Stop() - Write-Verbose ("[Get-AllDeviceData] Compliance setting-states batch: {0:N2}s ({1} pairs)" -f $swStep.Elapsed.TotalSeconds, $settingStatePairs.Count) - - $failedPairs = [System.Collections.Generic.List[object]]::new() - $pairIndex = @{} - foreach ($p in $settingStatePairs) { $pairIndex[$p.Id] = $p } - - foreach ($resp in $settingResponses) { - if ($resp.Status -ne 200 -or -not $resp.Body) { - if ($pairIndex.ContainsKey($resp.Id)) { $failedPairs.Add($pairIndex[$resp.Id]) } - continue - } - $deviceId = ($resp.Id -replace '^ss:', '') -split '\|' | Select-Object -First 1 - if (-not $ComplianceRulesByDevice.ContainsKey($deviceId)) { continue } - $details = @($resp.Body.value | Where-Object { $_.state -match 'nonCompliant' }) - foreach ($det in $details) { - # settingName (e.g. Windows10CompliancePolicy.RtpEnabled) is always set and maps to a friendly name; `setting` is empty for Default Device Compliance Policy rows. - $rule = if ($det.settingName) { $det.settingName } else { $det.setting } - if ($rule) { $ComplianceRulesByDevice[$deviceId].Add($rule) } - } - } - - # Fallback: sub-requests that didn't return 200 from $batch get retried as - # per-request GETs. Graph's $batch endpoint occasionally rejects this - # setting-states sub-route with 400 even though the individual GET works. - if ($failedPairs.Count -gt 0) { - $swStep.Restart() - foreach ($pair in $failedPairs) { - $deviceId = ($pair.Id -replace '^ss:', '') -split '\|' | Select-Object -First 1 - if (-not $ComplianceRulesByDevice.ContainsKey($deviceId)) { continue } - try { - $direct = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta$($pair.Url)" -ErrorAction Stop - $details = @($direct.value | Where-Object { $_.state -match 'nonCompliant' }) - foreach ($det in $details) { - $rule = if ($det.settingName) { $det.settingName } else { $det.setting } - if ($rule) { $ComplianceRulesByDevice[$deviceId].Add($rule) } - } - } catch { - Write-Verbose "Compliance setting-states fallback: per-request GET failed for $($pair.Id): $($_.Exception.Message)" - } - } - $swStep.Stop() - Write-Verbose ("[Get-AllDeviceData] Compliance setting-states fallback: {0:N2}s ({1} pairs retried individually)" -f $swStep.Elapsed.TotalSeconds, $failedPairs.Count) - } - } - } - - # Loop through all devices for device data - $results = [System.Collections.Generic.List[PSObject]]::new() - $totalDevices = $AllDeviceData.Count - - Write-Host "Processing $totalDevices devices..." -ForegroundColor Yellow - $swStep.Restart() - - for ($i = 0; $i -lt $AllDeviceData.Count; $i++) { - $DeviceData = $AllDeviceData[$i] - $currentIndex = $i + 1 - - # Calculate progress percentage - $progressPercent = [math]::Round(($currentIndex / $totalDevices) * 100, 1) - - # Show progress bar instead of Write-Host - Write-Progress -Activity "Processing Intune Devices" -Status "Processing device: $($DeviceData.DeviceName)" -CurrentOperation "$currentIndex of $totalDevices devices processed" -PercentComplete $progressPercent - - try { - # Use bulk-fetched data directly (no per-device re-fetch needed - same $select) - $DeviceProperties = $DeviceData - - # Process Autopilot information via pre-built hashtable - $AutopilotInfo = $AutopilotLookup[$DeviceData.SerialNumber] - $HashUploaded = $AutopilotLookup.ContainsKey($DeviceData.SerialNumber) - - # Compliance rules were prefetched via $batch above; just look up. - $FilteredForAlerting = @("DefaultDeviceCompliancePolicy.RequireDeviceCompliancePolicyAssigned", "DefaultDeviceCompliancePolicy.RequireRemainContact") - $uniqueRules = @() - if ($DeviceData.complianceState -eq 'noncompliant' -and $ComplianceRulesByDevice.ContainsKey($DeviceData.id)) { - $uniqueRules = @($ComplianceRulesByDevice[$DeviceData.id] | Select-Object -Unique) - } - - # Check if all logged in user ID's still exist in Microsoft Entra ID - $LoggedInUsers = $DeviceProperties.usersLoggedOn.userId | Select-Object -Unique - $ExistingLoggedInUsers = [System.Collections.Generic.List[string]]::new() - - if ($LoggedInUsers) { - foreach ($user in $LoggedInUsers) { - if ($UserLookup.ContainsKey($user)) { - $ExistingLoggedInUsers.Add($UserLookup[$user]) - } - } - } - - # Handle storage calculations with null checking - $TotalStorageFormatted = if ($DeviceProperties.TotalStorageSpaceInBytes -and $DeviceProperties.TotalStorageSpaceInBytes -gt 0) { - Convert-Size -From bytes -To GB -Value $DeviceProperties.TotalStorageSpaceInBytes -Precision 2 - } else { - "N/A" - } - - $FreeStorageFormatted = if ($DeviceProperties.FreeStorageSpaceInBytes -and $DeviceProperties.FreeStorageSpaceInBytes -gt 0) { - Convert-Size -From bytes -To GB -Value $DeviceProperties.FreeStorageSpaceInBytes -Precision 2 - } else { - "N/A" - } - - # Access hardware information with null checking - $hardwareInfo = $DeviceProperties.hardwareInformation - - # Read the Azure AD device id defensively. Graph normalises the property to - # `azureADDeviceId` (capital AD); some object types lose that on dot-access, - # and on a few records only the legacy `azureActiveDirectoryDeviceId` is set. - $resolvedAadDeviceId = $null - foreach ($propName in 'azureADDeviceId','azureAdDeviceId','AzureAdDeviceId','azureActiveDirectoryDeviceId','AzureActiveDirectoryDeviceId') { - $p = $DeviceProperties.PSObject.Properties[$propName] - if ($p -and $p.Value) { $resolvedAadDeviceId = [string]$p.Value; break } - } - - $results.Add([PSCustomObject][ordered]@{ - Customer = $TenantName - DeviceName = $DeviceProperties.DeviceName - AzureAdDeviceId = $resolvedAadDeviceId - DeviceOwnership = $DeviceProperties.ManagedDeviceOwnerType - PrimaryUser = if ($DeviceProperties.UserPrincipalName) { $DeviceProperties.UserPrincipalName } else { "None" } - Serialnumber = $DeviceProperties.SerialNumber - DeviceManufacturer = $DeviceProperties.Manufacturer - DeviceModel = $DeviceProperties.Model - ProcessorArchitecture = if ($hardwareInfo.processorArchitecture) { $hardwareInfo.processorArchitecture } else { $DeviceProperties.processorArchitecture } - TPMversion = if ($hardwareInfo.tpmVersion) { $hardwareInfo.tpmVersion } else { "Unknown" } - tpmSpecificationVersion = if ($hardwareInfo.tpmSpecificationVersion) { $hardwareInfo.tpmSpecificationVersion } else { "Unknown" } - WiFiMAC = $DeviceProperties.WiFiMacAddress - EthernetMAC = $DeviceProperties.EthernetMacAddress - TotalStorage = $TotalStorageFormatted - FreeStorage = $FreeStorageFormatted - EnrolledDate = $DeviceProperties.EnrolledDateTime - LastContact = $DeviceProperties.LastSyncDateTime - DeviceHashUploaded = $HashUploaded - DeviceAssociated = [bool]($DeviceData.SerialNumber -and $CorporateIdentifierSerials.Contains($DeviceData.SerialNumber)) - IsCloudPC = $CloudPcIds.Contains([string]$DeviceData.id) - AutopilotGroupTag = $AutopilotInfo.groupTag - AutopilotAssignedUser = if ($AutopilotInfo.userprincipalname) { $AutopilotInfo.userprincipalname } else { $null } - EnrollmentProfile = $DeviceProperties.EnrollmentProfileName - Encrypted = $DeviceProperties.IsEncrypted - DeviceEnrollmentType = $DeviceProperties.DeviceEnrollmentType - usersLoggedOnIds = if ($ExistingLoggedInUsers) { $ExistingLoggedInUsers -join ', ' } else { "" } - usersLoggedOnCount = if ($LoggedInUsers) { $LoggedInUsers.Count } else { 0 } - Operatingsystem = $DeviceProperties.OperatingSystem - OperatingSystemVersion = $DeviceProperties.OSVersion - OSFriendlyname = Get-OSFriendlyName -OperatingSystemVersion $DeviceProperties.OSVersion - OperatingSystemLanguage = if ($hardwareInfo.operatingSystemLanguage) { $hardwareInfo.operatingSystemLanguage } else { "Unknown" } - OperatingSystemEdition = if ($DeviceProperties.skuFamily) { $DeviceProperties.skuFamily } elseif ($hardwareInfo.operatingSystemEdition) { $hardwareInfo.operatingSystemEdition } else { "Unknown" } - operatingSystemProductType = if ($hardwareInfo.operatingSystemProductType) { Get-OperatingSystemProductType -Customer "$($hardwareInfo.operatingSystemProductType)" } else { "Unknown" } - BiosVersion = if ($hardwareInfo.systemManagementBIOSVersion) { $hardwareInfo.systemManagementBIOSVersion } else { "Unknown" } - ComplianceStatus = $DeviceProperties.ComplianceState - # **FIX**: Use unique rules to prevent duplicates - NoncompliantBasedOn = (@($uniqueRules | ForEach-Object { ConvertTo-ComplianceSettingFriendlyName $_ }) | Select-Object -Unique) -join ', ' - NoncompliantAlert = (@($uniqueRules | Where-Object { $_ -notin $FilteredForAlerting } | ForEach-Object { ConvertTo-ComplianceSettingFriendlyName $_ }) | Select-Object -Unique) -join ', ' - }) - } catch { - Write-Warning "Error processing device $($DeviceData.DeviceName): $_" - continue - } - } - - # Clear the progress bar when done - Write-Progress -Activity "Processing Intune Devices" -Completed - $swStep.Stop() - Write-Verbose ("[Get-AllDeviceData] Per-device projection loop: {0:N2}s" -f $swStep.Elapsed.TotalSeconds) - $swTotal.Stop() - Write-Verbose ("[Get-AllDeviceData] TOTAL: {0:N2}s" -f $swTotal.Elapsed.TotalSeconds) - - Write-Host "Device processing completed!" -ForegroundColor Green - Write-Host "Processed $($results.Count) devices out of $totalDevices total devices" -ForegroundColor Green - - # Debug output for hardware information availability - $devicesWithoutHardwareInfo = $results | Where-Object { $_.TPMversion -eq "Unknown" } - - if ($devicesWithoutHardwareInfo.Count -gt 0) { - Write-Host "Some devices are missing hardware information:" -ForegroundColor Yellow - foreach ($device in $devicesWithoutHardwareInfo) { - Write-Host " - $($device.DeviceName) (Serial: $($device.Serialnumber))" -ForegroundColor Gray - } - } - return $results - -} - -function Get-ApplicationFailures { - - # Cross-platform temporary file path - # Detect OS and set appropriate temp path - $detectedWindows = $false - - # Check if automatic variables exist (PowerShell Core 6.0+) - if (Get-Variable -Name "IsWindows" -ErrorAction SilentlyContinue) { - $detectedWindows = $IsWindows - } - # Fallback for older PowerShell versions - else { - $osInfo = [System.Environment]::OSVersion.Platform - switch ($osInfo) { - "Win32NT" { $detectedWindows = $true } - "Unix" { - # macOS/Linux detected but not used in this context - } - default { - try { - if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { - $detectedWindows = $true - } - else { - # Non-Windows OS detected - } - } catch { - $detectedWindows = $true - } - } - } - } - - # Use a unique temporary file to avoid race conditions - $Data = [System.IO.Path]::GetTempFileName() - - $apps = (Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?`$filter=(isof(%27microsoft.graph.win32CatalogApp%27)%20or%20isof(%27microsoft.graph.windowsStoreApp%27)%20or%20isof(%27microsoft.graph.microsoftStoreForBusinessApp%27)%20or%20isof(%27microsoft.graph.officeSuiteApp%27)%20or%20(isof(%27microsoft.graph.win32LobApp%27)%20and%20not(isof(%27microsoft.graph.win32CatalogApp%27)))%20or%20isof(%27microsoft.graph.windowsMicrosoftEdgeApp%27)%20or%20isof(%27microsoft.graph.windowsPhone81AppX%27)%20or%20isof(%27microsoft.graph.windowsPhone81StoreApp%27)%20or%20isof(%27microsoft.graph.windowsPhoneXAP%27)%20or%20isof(%27microsoft.graph.windowsAppX%27)%20or%20isof(%27microsoft.graph.windowsMobileMSI%27)%20or%20isof(%27microsoft.graph.windowsUniversalAppX%27)%20or%20isof(%27microsoft.graph.webApp%27)%20or%20isof(%27microsoft.graph.windowsWebApp%27)%20or%20isof(%27microsoft.graph.winGetApp%27))%20and%20(microsoft.graph.managedApp/appAvailability%20eq%20null%20or%20microsoft.graph.managedApp/appAvailability%20eq%20%27lineOfBusiness%27%20or%20isAssigned%20eq%20true)&`$orderby=displayName&").value - - $params = @{ - Select = @( - "DisplayName" - "Publisher" - "Platform" - "AppVersion" - "FailedDevicePercentage" - "FailedDeviceCount" - "FailedUserCount" - "ApplicationId" - ) - Skip = 0 - Top = 50 - Filter = "(FailedDeviceCount gt '0')" - OrderBy = @( - "FailedDeviceCount desc" - ) - } - Invoke-MgGraphRequest -Body $params -Uri "https://graph.microsoft.com/beta/deviceManagement/reports/getAppsInstallSummaryReport" -Method POST -OutputFilePath $Data - - $DataFile = Get-Content $Data - # Fix char encoding to UTF-8 - $Response = [system.Text.Encoding]::UTF8.GetString(($DataFile).ToCharArray()) | ConvertFrom-Json - - # Pre-build app lookup (id -> app) so the per-row joins are O(1) instead of O(N) Where-Object scans. - $appLookup = @{} - foreach ($a in $apps) { if ($a.Id) { $appLookup[[string]$a.Id] = $a } } - - # First pass: materialise the schema-flattened rows that have a matching app, - # collecting unique ApplicationIds so we can batch-fetch their assignments. - $rows = [System.Collections.Generic.List[hashtable]]::new() - $expandIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($value in $Response.Values) { - $LineObject = @{ } - foreach ($prop in $Response.Schema) { - $LineObject[$prop.Column] = $value[$Response.Schema.IndexOf($prop)] - } - $appId = [string]$LineObject.ApplicationId - if ($appLookup.ContainsKey($appId)) { - $rows.Add($LineObject) - [void]$expandIds.Add($appId) - } - } - - # Batch-fetch ?$expand=assignments for every failed app in parallel sub-requests. - $assignmentStatusByAppId = @{} - if ($expandIds.Count -gt 0) { - $expandRequests = foreach ($id in $expandIds) { - [PSCustomObject]@{ - Id = "app:$id" - Url = "/deviceAppManagement/mobileApps/$id/?`$expand=assignments" - } - } - $expandResponses = Invoke-RKGraphBatch -Requests @($expandRequests) -Activity 'App assignments' - foreach ($resp in $expandResponses) { - if ($resp.Status -ne 200 -or -not $resp.Body) { continue } - $appId = $resp.Id -replace '^app:', '' - $hasAssignments = $resp.Body.assignments -and (@($resp.Body.assignments).Count -gt 0) - $assignmentStatusByAppId[$appId] = [bool]$hasAssignments - } - } - - # Build result array from the prepared rows using O(1) lookups. - $ReturnObject = New-Object System.Collections.ArrayList - foreach ($LineObject in $rows) { - $appId = [string]$LineObject.ApplicationId - $PlatformName = if ($LineObject.Platform_loc) { $LineObject.Platform_loc } else { $LineObject.Platform } - $AssignmentStatus = $false - if ($assignmentStatusByAppId.ContainsKey($appId)) { $AssignmentStatus = $assignmentStatusByAppId[$appId] } - - $ReturnObject.Add([PSCustomObject][ordered]@{ - Customer = $tenantname - Application = $appLookup[$appId].displayName - Platform = $PlatformName - Version = $LineObject.AppVersion - AssignmentStatus = $AssignmentStatus - FailedUserCount = $LineObject.FailedUserCount - FailedDeviceCount = $LineObject.FailedDeviceCount - FailedDevicePercentage = [math]::Round([double]$LineObject.FailedDevicePercentage, 2) - }) | Out-Null - } - - # Clean up temporary data file - if (Test-Path -Path $Data) { - Remove-Item -Path $Data -Force - } - - return $ReturnObject | Sort-Object -Property FailedDeviceCount -Descending -} - -function Get-AutopilotProfilesInformation { -(Invoke-GraphRequest -Uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/" -OutputType PSObject).value -} - -# ---------------------------------------------------------------------------- -# BitLocker key escrow & Windows LAPS backup discovery. -# Determines per-device whether a BitLocker policy and a Windows LAPS (Entra- -# backed) policy are actually applied (via assignment resolution incl. groups, -# all users/devices, and assignment filters), and cross-references with the -# recovery key / local credential directory endpoints to flag escrow gaps. -# Reuses Invoke-GraphRequestWithPaging, Get-DetailedPolicyAssignments, -# Test-IntuneFilter, and the $script:AllFilters cache established by -# IntuneEnrollmentFlows.ps1 - load order is enforced in RKSolutions.psm1. -# ---------------------------------------------------------------------------- - -function Get-BitLockerLapsAssignmentContext { - <# - Bulk-fetches all Settings Catalog and legacy device configuration policies that - target BitLocker or Windows LAPS, resolves their assignments, fetches assignment - filters, primes group transitive member sets for groups referenced by those - assignments, and pulls the BitLocker recovery key and LAPS local credential - directories. Returns a single context object consumed by the per-device evaluators. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory = $false)] [switch] $DebugMode - ) - - $context = [PSCustomObject]@{ - BitLockerPolicies = [System.Collections.Generic.List[object]]::new() - LapsPolicies = [System.Collections.Generic.List[object]]::new() - GroupDeviceAadIds = @{} # groupId -> HashSet of azureAdDeviceId - GroupUserUpns = @{} # groupId -> HashSet of lowercased UPN - GroupUserIds = @{} # groupId -> HashSet of user object id - BitLockerKeyDeviceIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - # Both LAPS deviceLocalCredentialInfo.id and bitlockerRecoveryKey.deviceId are - # the Azure AD device id (= managedDevice.azureADDeviceId). Verified against - # live Graph data. Both joins go through that single GUID. - LapsCredentialByDeviceId = @{} # azureAdDeviceId -> LAPS credential record - LapsCredentialByDeviceName = @{} # lowercased deviceName -> LAPS credential record (fallback join) - # Safety-net for device records that lack azureADDeviceId in Intune: lookup - # the Entra device by displayName, recover its deviceId, retry the join. - EntraDeviceIdByName = @{} # lowercased deviceName -> azureAdDeviceId - Filters = @{} # filterId -> filter object (mirror of $script:AllFilters) - # Raw configurationPolicies?$expand=settings result, retained so the - # deprecation walker can iterate every Settings Catalog policy without - # a second Graph round-trip. - ConfigurationPolicies = @() - } - - # 1. Prime assignment filters cache (used by Get-DetailedPolicyAssignments + Test-IntuneFilter). - if ($script:AllFilters.Count -eq 0) { - try { - $rawFilters = Invoke-GraphRequestWithPaging -Uri 'https://graph.microsoft.com/beta/deviceManagement/assignmentFilters' - if ($rawFilters) { foreach ($f in $rawFilters) { $script:AllFilters[$f.id] = $f } } - } - catch { Write-Verbose "Failed to load assignment filters: $($_.Exception.Message)" } - } - $context.Filters = $script:AllFilters - - # 2. Discover Settings Catalog policies that contain BitLocker or LAPS settings. - # Two-step pattern: list policies (small payload, fits in 1-2 pages) then $batch-fetch - # /settings per policy. Avoids Graph hard-capping page size when $expand=settings is used - # (observed ~1-2 policies per page on cosmos-backed tenants); batch sub-requests execute - # server-side in parallel, so ~75 policies finish in ~4 batched round trips. - Write-Host ' Discovering Settings Catalog policies (BitLocker / LAPS)...' -ForegroundColor Cyan - $configPolicies = @() - $swCfg = [System.Diagnostics.Stopwatch]::StartNew() - try { - $configPolicies = Invoke-GraphRequestWithPaging -Uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?$top=999' - } - catch { Write-Warning "configurationPolicies list fetch failed: $($_.Exception.Message)" } - $swCfg.Stop() - Write-Verbose ("[BitLockerLaps] configurationPolicies list: {0:N2}s ({1} policies)" -f $swCfg.Elapsed.TotalSeconds, $configPolicies.Count) - - if ($configPolicies.Count -gt 0) { - $swSet = [System.Diagnostics.Stopwatch]::StartNew() - $settingsRequests = foreach ($p in $configPolicies) { - [PSCustomObject]@{ - Id = "cps:$($p.id)" - Url = "/deviceManagement/configurationPolicies/$($p.id)/settings" - } - } - $settingsResponses = Invoke-RKGraphBatch -Requests @($settingsRequests) -Activity 'Settings Catalog policy settings' - $settingsByPolicyId = @{} - foreach ($resp in $settingsResponses) { - if ($resp.Status -ne 200 -or -not $resp.Body) { continue } - $pid = $resp.Id -replace '^cps:', '' - $settingsByPolicyId[$pid] = @($resp.Body.value) - } - # Attach settings back onto each policy so the existing loop below stays unchanged. - foreach ($p in $configPolicies) { - $s = $null - if ($settingsByPolicyId.ContainsKey($p.id)) { $s = $settingsByPolicyId[$p.id] } - if ($p.PSObject.Properties['settings']) { $p.settings = $s } - else { $p | Add-Member -NotePropertyName settings -NotePropertyValue $s -Force } - } - $swSet.Stop() - Write-Verbose ("[BitLockerLaps] Settings batch-fetch: {0:N2}s ({1} policies)" -f $swSet.Elapsed.TotalSeconds, $configPolicies.Count) - } - $context.ConfigurationPolicies = $configPolicies - - foreach ($policy in $configPolicies) { - if (-not $policy.settings) { continue } - $hasBitLocker = $false; $hasLaps = $false; $lapsBackupValue = $null - foreach ($s in $policy.settings) { - $defId = $null - if ($s.settingInstance -and $s.settingInstance.settingDefinitionId) { $defId = [string]$s.settingInstance.settingDefinitionId } - if (-not $defId) { continue } - $defIdLower = $defId.ToLowerInvariant() - if ($defIdLower.StartsWith($script:BitLockerSettingPrefix)) { $hasBitLocker = $true } - if ($defIdLower.StartsWith($script:LapsSettingPrefix)) { $hasLaps = $true } - if ($defIdLower -eq $script:LapsBackupDirectorySetting) { - # Choice settings expose the chosen value via choiceSettingValue.value, suffixed with the choice integer. - $val = $null - if ($s.settingInstance.choiceSettingValue -and $s.settingInstance.choiceSettingValue.value) { - $val = [string]$s.settingInstance.choiceSettingValue.value - } - if ($val) { - if ($val -match '_1$') { $lapsBackupValue = 1 } # Azure AD (Entra) - elseif ($val -match '_2$') { $lapsBackupValue = 2 } # On-prem AD - elseif ($val -match '_0$') { $lapsBackupValue = 0 } # Disabled - } - } - } - $displayName = if ($policy.name) { $policy.name } else { $policy.displayName } - if ($hasBitLocker) { - $assignments = Get-DetailedPolicyAssignments -EntityType 'configurationPolicies' -EntityId $policy.id -PolicyName $displayName -DebugMode:$DebugMode - $context.BitLockerPolicies.Add([PSCustomObject]@{ - Id = $policy.id - DisplayName = $displayName - Source = 'SettingsCatalog' - Assignments = @($assignments) - }) - } - if ($hasLaps -and $lapsBackupValue -eq 1) { - # Only Entra-backed LAPS policies are relevant to "can we read it from Entra?" - $assignments = Get-DetailedPolicyAssignments -EntityType 'configurationPolicies' -EntityId $policy.id -PolicyName $displayName -DebugMode:$DebugMode - $context.LapsPolicies.Add([PSCustomObject]@{ - Id = $policy.id - DisplayName = $displayName - Source = 'SettingsCatalog' - BackupDirectory = $lapsBackupValue - Assignments = @($assignments) - }) - } - } - - # 3. Discover legacy Endpoint Protection device configurations that include BitLocker settings. - Write-Host ' Discovering legacy device configuration policies (BitLocker)...' -ForegroundColor Cyan - try { - $deviceConfigs = Invoke-GraphRequestWithPaging -Uri 'https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations' - foreach ($cfg in $deviceConfigs) { - $odataType = [string]$cfg.'@odata.type' - if ($odataType -eq '#microsoft.graph.windows10EndpointProtectionConfiguration') { - $assignments = Get-DetailedPolicyAssignments -EntityType 'deviceConfigurations' -EntityId $cfg.id -PolicyName $cfg.displayName -DebugMode:$DebugMode - $context.BitLockerPolicies.Add([PSCustomObject]@{ - Id = $cfg.id - DisplayName = $cfg.displayName - Source = 'DeviceConfiguration' - Assignments = @($assignments) - }) - } - } - } - catch { Write-Warning "deviceConfigurations fetch failed: $($_.Exception.Message)" } - - # 4. Discover Endpoint Security intents (BitLocker disk encryption template). - Write-Host ' Discovering Endpoint Security intents (BitLocker)...' -ForegroundColor Cyan - try { - $intents = Invoke-GraphRequestWithPaging -Uri 'https://graph.microsoft.com/beta/deviceManagement/intents' - foreach ($intent in $intents) { - $name = [string]$intent.displayName - # No reliable filter for BitLocker template; match by name as a pragmatic discovery hint. - if ($name -match '(?i)bitlocker' -or $name -match '(?i)disk\s*encryption') { - $assignments = Get-DetailedPolicyAssignments -EntityType 'deviceManagement/intents' -EntityId $intent.id -PolicyName $name -DebugMode:$DebugMode - $context.BitLockerPolicies.Add([PSCustomObject]@{ - Id = $intent.id - DisplayName = $name - Source = 'Intent' - Assignments = @($assignments) - }) - } - } - } - catch { Write-Verbose "intents fetch skipped: $($_.Exception.Message)" } - - # 5. Collect every groupId referenced by any of those policies' assignments, then - # fetch transitive members for each group exactly once. - $referencedGroupIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($p in @($context.BitLockerPolicies + $context.LapsPolicies)) { - foreach ($a in $p.Assignments) { - if ($a.GroupId) { [void]$referencedGroupIds.Add([string]$a.GroupId) } - } - } - Write-Host " Resolving transitive members for $($referencedGroupIds.Count) group(s) used by BitLocker/LAPS assignments..." -ForegroundColor Cyan - # Initialise empty sets up front so every referenced group has entries even if a fetch fails. - foreach ($gid in $referencedGroupIds) { - $context.GroupDeviceAadIds[$gid] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - $context.GroupUserUpns[$gid] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - $context.GroupUserIds[$gid] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - } - if ($referencedGroupIds.Count -gt 0) { - $memberRequests = [System.Collections.Generic.List[object]]::new() - foreach ($gid in $referencedGroupIds) { - $memberRequests.Add([PSCustomObject]@{ - Id = "gd:$gid" - Url = "/groups/$gid/transitiveMembers/microsoft.graph.device?`$select=id,deviceId" - }) - $memberRequests.Add([PSCustomObject]@{ - Id = "gu:$gid" - Url = "/groups/$gid/transitiveMembers/microsoft.graph.user?`$select=id,userPrincipalName" - }) - } - # transitiveMembers lives on v1.0; route the batch there to keep sub-request URLs consistent. - $memberResponses = Invoke-RKGraphBatch -Requests @($memberRequests) -GraphVersion 'v1.0' -Activity 'Group transitive members' - # Any sub-response carrying @odata.nextLink overflows the first page (999 members). - # Fall back to sequential paging for those groups only — rare in practice. - $nextLinkFollowups = [System.Collections.Generic.List[object]]::new() - foreach ($resp in $memberResponses) { - if ($resp.Status -ne 200 -or -not $resp.Body) { continue } - $isUser = $resp.Id.StartsWith('gu:') - $gid = $resp.Id.Substring(3) - foreach ($m in @($resp.Body.value)) { - if ($isUser) { - if ($m.id) { [void]$context.GroupUserIds[$gid].Add([string]$m.id) } - if ($m.userPrincipalName) { [void]$context.GroupUserUpns[$gid].Add(([string]$m.userPrincipalName).ToLowerInvariant()) } - } else { - if ($m.deviceId) { [void]$context.GroupDeviceAadIds[$gid].Add([string]$m.deviceId) } - } - } - if ($resp.Body.'@odata.nextLink') { - $nextLinkFollowups.Add([PSCustomObject]@{ Gid = $gid; IsUser = $isUser; NextUri = [string]$resp.Body.'@odata.nextLink' }) - } - } - foreach ($f in $nextLinkFollowups) { - try { - $rest = Invoke-GraphRequestWithPaging -Uri $f.NextUri - foreach ($m in $rest) { - if ($f.IsUser) { - if ($m.id) { [void]$context.GroupUserIds[$f.Gid].Add([string]$m.id) } - if ($m.userPrincipalName) { [void]$context.GroupUserUpns[$f.Gid].Add(([string]$m.userPrincipalName).ToLowerInvariant()) } - } else { - if ($m.deviceId) { [void]$context.GroupDeviceAadIds[$f.Gid].Add([string]$m.deviceId) } - } - } - } - catch { Write-Verbose "Group $($f.Gid) nextLink follow-up failed: $($_.Exception.Message)" } - } - } - - # 6. BitLocker recovery keys. OS-volume key (volumeType == 1) is what protects the system drive. - # Use Invoke-MgGraphRequest directly (not the swallow-on-error paging helper) so a - # missing scope surfaces as a clear warning rather than an empty result set. - Write-Host ' Fetching BitLocker recovery key index (OS volume)...' -ForegroundColor Cyan - $bitLockerFetched = 0 - try { - $bitlockerUri = 'https://graph.microsoft.com/v1.0/informationProtection/bitlocker/recoveryKeys?$select=id,deviceId,volumeType,createdDateTime' - do { - $resp = Invoke-MgGraphRequest -Uri $bitlockerUri -Method GET -OutputType PSObject -ErrorAction Stop - if ($resp.value) { - foreach ($k in $resp.value) { - $bitLockerFetched++ - if ([int]$k.volumeType -eq 1 -and $k.deviceId) { [void]$context.BitLockerKeyDeviceIds.Add([string]$k.deviceId) } - } - } - $bitlockerUri = $resp.'@odata.nextLink' - } while ($bitlockerUri) - Write-Host " Indexed $($context.BitLockerKeyDeviceIds.Count) device(s) with OS-volume BitLocker keys (from $bitLockerFetched key rotations)." -ForegroundColor Green - } - catch { - $msg = $_.Exception.Message - if ($msg -match '403|Forbidden|Authorization|scopes') { - Write-Warning "BitLocker recoveryKeys fetch DENIED. Most likely cause: the connected app is missing BitlockerKey.ReadBasic.All. Without it, every encrypted device will be flagged 'no key in Entra'. Reconnect with: Disconnect-RKGraph; Connect-RKGraph" - } - else { Write-Warning "BitLocker recoveryKeys fetch failed: $msg" } - } - - # 7. LAPS local credentials directory. Entry's `id` field IS the Azure AD device id - # (verified against live Graph). Key the hashtable by it directly. - Write-Host ' Fetching directory/deviceLocalCredentials (Windows LAPS)...' -ForegroundColor Cyan - $lapsFetched = 0 - try { - $lapsUri = 'https://graph.microsoft.com/v1.0/directory/deviceLocalCredentials?$select=id,deviceName,lastBackupDateTime,refreshDateTime' - do { - $resp = Invoke-MgGraphRequest -Uri $lapsUri -Method GET -OutputType PSObject -ErrorAction Stop - if ($resp.value) { - foreach ($e in $resp.value) { - $lapsFetched++ - if ($e.id) { $context.LapsCredentialByDeviceId[[string]$e.id] = $e } - if ($e.deviceName) { - $context.LapsCredentialByDeviceName[([string]$e.deviceName).ToLowerInvariant()] = $e - } - } - } - $lapsUri = $resp.'@odata.nextLink' - } while ($lapsUri) - Write-Host " Indexed $lapsFetched LAPS local credential record(s)." -ForegroundColor Green - } - catch { - $msg = $_.Exception.Message - if ($msg -match '403|Forbidden|Authorization|scopes') { - Write-Warning "deviceLocalCredentials fetch DENIED. Most likely cause: the connected app is missing DeviceLocalCredential.ReadBasic.All. Without it, every device will be flagged 'no LAPS backup'. Reconnect with: Disconnect-RKGraph; Connect-RKGraph" - } - else { Write-Warning "deviceLocalCredentials fetch failed: $msg" } - } - - # 8. Safety-net deviceName -> deviceId map. Only used when a managedDevice record - # has no azureADDeviceId of its own (rare, but seen on partially-enrolled devices). - Write-Host ' Building Entra device name -> deviceId safety map...' -ForegroundColor Cyan - try { - $entraDevices = Invoke-GraphRequestWithPaging -Uri 'https://graph.microsoft.com/v1.0/devices?$select=deviceId,displayName' - foreach ($ed in $entraDevices) { - if ($ed.deviceId -and $ed.displayName) { - $context.EntraDeviceIdByName[([string]$ed.displayName).ToLowerInvariant()] = [string]$ed.deviceId - } - } - Write-Host " Mapped $($context.EntraDeviceIdByName.Count) Entra device(s) by displayName." -ForegroundColor Green - } - catch { Write-Warning "/v1.0/devices fetch failed: $($_.Exception.Message)" } - - return $context -} - -function Resolve-DeviceAzureAdDeviceId { - <# - Return the Azure AD device id to use for joining with BitLocker recovery keys - and LAPS credentials. Prefer the value already on the device record; fall back - to the deviceName -> deviceId map populated by Get-BitLockerLapsAssignmentContext. - #> - param([Parameter(Mandatory)] [PSCustomObject] $Device, [Parameter(Mandatory)] [PSCustomObject] $Context) - if ($Device.AzureAdDeviceId) { return [string]$Device.AzureAdDeviceId } - if ($Device.DeviceName) { - $key = ([string]$Device.DeviceName).ToLowerInvariant() - if ($Context.EntraDeviceIdByName.ContainsKey($key)) { return $Context.EntraDeviceIdByName[$key] } - } - return $null -} - -function ConvertTo-FilterDeviceProperties { - <# - Map a Get-AllDeviceData record onto the property names that Test-IntuneFilter - recognises (deviceName, operatingSystem, osVersion, manufacturer, model, - isEncrypted, ownerType, serialNumber, enrollmentProfileName, complianceState). - #> - param([Parameter(Mandatory)] [PSCustomObject] $Device) - [PSCustomObject]@{ - DeviceName = $Device.DeviceName - OperatingSystem = $Device.Operatingsystem - OSVersion = $Device.OperatingSystemVersion - Manufacturer = $Device.DeviceManufacturer - Model = $Device.DeviceModel - IsEncrypted = $Device.Encrypted - OwnerType = $Device.DeviceOwnership - SerialNumber = $Device.Serialnumber - EnrollmentProfileName = $Device.EnrollmentProfile - ComplianceState = $Device.ComplianceStatus - ProcessorArchitecture = $Device.ProcessorArchitecture - DeviceType = 'desktop' - UserPrincipalName = $Device.PrimaryUser - AzureAdDeviceId = $Device.AzureAdDeviceId - } -} - -function Test-AssignmentAppliesToManagedDevice { - <# - Evaluates a single Get-DetailedPolicyAssignments row against a managed device - record using the pre-resolved group membership sets in the context. Returns - @{ Applies = $bool; FilterDecision = '...' }. Assignment filters are honored - via Test-IntuneFilter (provided by IntuneEnrollmentFlows.ps1). - #> - param( - [Parameter(Mandatory)] [PSCustomObject] $Assignment, - [Parameter(Mandatory)] [PSCustomObject] $Device, - [Parameter(Mandatory)] [PSCustomObject] $Context - ) - - $aadDeviceId = if ($Device.AzureAdDeviceId) { [string]$Device.AzureAdDeviceId } else { '' } - $primaryUpn = if ($Device.PrimaryUser) { ([string]$Device.PrimaryUser).ToLowerInvariant() } else { '' } - $baseApplies = $false - - switch ($Assignment.AssignmentType) { - 'All Devices' { $baseApplies = $true } - 'All Users' { $baseApplies = -not [string]::IsNullOrEmpty($primaryUpn) } - 'Group (Include)' { - if ($Assignment.GroupId) { - $gid = [string]$Assignment.GroupId - $inDeviceGroup = $false; $inUserGroup = $false - if ($Context.GroupDeviceAadIds.ContainsKey($gid) -and $aadDeviceId) { - $inDeviceGroup = $Context.GroupDeviceAadIds[$gid].Contains($aadDeviceId) - } - if ($Context.GroupUserUpns.ContainsKey($gid) -and $primaryUpn) { - $inUserGroup = $Context.GroupUserUpns[$gid].Contains($primaryUpn) - } - $baseApplies = $inDeviceGroup -or $inUserGroup - } - } - 'Group (Exclude)' { $baseApplies = $false } # Handled explicitly by caller as an exclusion signal. - default { $baseApplies = $false } - } - - if (-not $baseApplies) { return [PSCustomObject]@{ Applies = $false; FilterDecision = 'N/A' } } - - # Assignment filter handling. Filter rules use property names like deviceName, - # operatingSystem, manufacturer, model, osVersion - all surfaced by Test-IntuneFilter. - $filterDecision = 'N/A' - $hasFilter = $Assignment.FilterId -and $Assignment.FilterId -ne '00000000-0000-0000-0000-000000000000' ` - -and $Assignment.FilterType -and $Assignment.FilterType -ne 'None' -and $Assignment.FilterType -ne 'none' - if ($hasFilter) { - $filter = $Context.Filters[$Assignment.FilterId] - if ($filter -and $filter.rule) { - $filterDeviceProps = ConvertTo-FilterDeviceProperties -Device $Device - $matched = Test-IntuneFilter -FilterRule $filter.rule -DeviceProperties $filterDeviceProps - $filterDecision = if ($matched) { 'Matched' } else { 'NotMatched' } - $ft = ([string]$Assignment.FilterType).ToLowerInvariant() - if ($ft -eq 'include') { if (-not $matched) { return [PSCustomObject]@{ Applies = $false; FilterDecision = $filterDecision } } } - elseif ($ft -eq 'exclude') { if ($matched) { return [PSCustomObject]@{ Applies = $false; FilterDecision = $filterDecision } } } - } - else { $filterDecision = 'FilterNotFound' } - } - - return [PSCustomObject]@{ Applies = $true; FilterDecision = $filterDecision } -} - -function Get-DeviceAppliedPolicies { - <# - Walks a list of policies (each carrying its full assignment set) and decides, - for the given device, whether each policy: - - applies -> goes in .Applied - - was explicitly excluded -> goes in .Excluded with a reason - ('ExcludeGroup' | 'AssignmentFilter') - - simply doesn't target it -> ignored (not anomalous in itself) - - A policy that targeted the device's group/user but was then rejected by an - assignment filter, or by an exclusion group, is an *intentional* exclusion - - callers can hide those from anomaly output by default and surface them under - an opt-in flag. - #> - param( - [Parameter(Mandatory)] [array] $Policies, - [Parameter(Mandatory)] [PSCustomObject] $Device, - [Parameter(Mandatory)] [PSCustomObject] $Context - ) - - $applied = [System.Collections.Generic.List[object]]::new() - $excluded = [System.Collections.Generic.List[object]]::new() - $aadDeviceId = if ($Device.AzureAdDeviceId) { [string]$Device.AzureAdDeviceId } else { '' } - $primaryUpn = if ($Device.PrimaryUser) { ([string]$Device.PrimaryUser).ToLowerInvariant() } else { '' } - - foreach ($policy in $Policies) { - # First pass: any exclude group that names this device's identity wins - # outright, regardless of include scope. - $excludeGroupHit = $false - foreach ($a in $policy.Assignments) { - if ($a.AssignmentType -ne 'Group (Exclude)' -or -not $a.GroupId) { continue } - $gid = [string]$a.GroupId - if ($aadDeviceId -and $Context.GroupDeviceAadIds.ContainsKey($gid) -and $Context.GroupDeviceAadIds[$gid].Contains($aadDeviceId)) { $excludeGroupHit = $true; break } - if ($primaryUpn -and $Context.GroupUserUpns.ContainsKey($gid) -and $Context.GroupUserUpns[$gid].Contains($primaryUpn)) { $excludeGroupHit = $true; break } - } - if ($excludeGroupHit) { - $excluded.Add([PSCustomObject]@{ PolicyName = $policy.DisplayName; Source = $policy.Source; Reason = 'ExcludeGroup' }) - continue - } - - # Second pass: walk include-style assignments. Track whether any base-level - # match happened (device was *targeted*) so we can distinguish a filter - # rejection ("intentional exclusion") from a plain non-match ("never in scope"). - $includeMatch = $null - $baseTargetedButFiltered = $false - foreach ($a in $policy.Assignments) { - if ($a.AssignmentType -eq 'Group (Exclude)' -or $a.AssignmentType -eq 'Not Assigned') { continue } - $result = Test-AssignmentAppliesToManagedDevice -Assignment $a -Device $Device -Context $Context - if ($result.Applies) { $includeMatch = $result; break } - # Applies=$false with a non-N/A FilterDecision == base matched, filter rejected. - if ($result.FilterDecision -in 'Matched','NotMatched','FilterNotFound') { $baseTargetedButFiltered = $true } - } - if ($includeMatch) { - $applied.Add([PSCustomObject]@{ PolicyName = $policy.DisplayName; Source = $policy.Source; FilterDecision = $includeMatch.FilterDecision }) - } - elseif ($baseTargetedButFiltered) { - $excluded.Add([PSCustomObject]@{ PolicyName = $policy.DisplayName; Source = $policy.Source; Reason = 'AssignmentFilter' }) - } - # else: policy didn't target this device at all. Not surfaced. - } - - return [PSCustomObject]@{ Applied = @($applied); Excluded = @($excluded) } -} - -function Resolve-IntuneBitLockerAnomalies { - <# - For each Windows managed device, decide whether BitLocker is governed by an - assigned Intune policy and whether the OS-volume recovery key is escrowed to - Entra. Emits one row per anomalous device with a severity bucket. - - A device that was deliberately excluded (via an exclude group or an assignment - filter that rejected it) is *not* an anomaly by default - that's the admin's - explicit intent. Pass -ShowExcludedDevices to surface those rows as Info. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] [array] $Devices, - [Parameter(Mandatory)] [PSCustomObject] $Context, - [Parameter(Mandatory)] [string] $TenantName, - [switch] $ShowExcludedDevices - ) - $out = [System.Collections.Generic.List[PSObject]]::new() - foreach ($d in $Devices) { - $aadId = Resolve-DeviceAzureAdDeviceId -Device $d -Context $Context - $isEncrypted = [bool]$d.Encrypted - - # No Entra device id: we can't evaluate policy assignment or key escrow - # for this device, but we still flag "not encrypted" since that's the - # one anomaly we can detect from the managed-device record alone. - if (-not $aadId) { - if (-not $isEncrypted) { - $out.Add([PSCustomObject][ordered]@{ - Customer = $TenantName - DeviceName = $d.DeviceName - PrimaryUser = $d.PrimaryUser - Serialnumber = $d.Serialnumber - IsEncrypted = 'No' - AppliedPolicies = '' - KeyEscrowed = 'Unknown' - Severity = 'Warning' - Status = 'Device not encrypted (no Azure AD device id to verify policy / key state)' - }) - } - continue - } - - $eval = Get-DeviceAppliedPolicies -Policies $Context.BitLockerPolicies -Device $d -Context $Context - $applied = $eval.Applied - $excluded = $eval.Excluded - $hasKey = $Context.BitLockerKeyDeviceIds.Contains($aadId) - - $status = $null; $severity = $null - if ($applied.Count -eq 0 -and $excluded.Count -gt 0) { - # Intentional exclusion. Skip unless caller asked to surface them. - if (-not $ShowExcludedDevices) { continue } - $reasons = ($excluded | Select-Object -ExpandProperty Reason -Unique) -join ', ' - $status = "Intentionally excluded from BitLocker policies ($reasons)" - $severity = 'Info' - } - elseif (-not $isEncrypted -and $applied.Count -eq 0) { $status = 'Device not encrypted and no BitLocker policy assigned'; $severity = 'Critical' } - elseif ($applied.Count -eq 0) { $status = 'No BitLocker policy assigned'; $severity = 'Warning' } - elseif ($isEncrypted -and -not $hasKey) { $status = 'Encrypted but no OS-volume key in Entra'; $severity = 'Critical' } - elseif (-not $isEncrypted) { $status = 'BitLocker policy assigned but device not encrypted'; $severity = 'Critical' } - else { continue } # Healthy. - - $out.Add([PSCustomObject][ordered]@{ - Customer = $TenantName - DeviceName = $d.DeviceName - PrimaryUser = $d.PrimaryUser - Serialnumber = $d.Serialnumber - IsEncrypted = if ($isEncrypted) { 'Yes' } else { 'No' } - AppliedPolicies = if ($applied.Count -gt 0) { ($applied | ForEach-Object { $_.PolicyName }) -join '; ' } else { ($excluded | ForEach-Object { "$($_.PolicyName) [$($_.Reason)]" }) -join '; ' } - KeyEscrowed = if ($hasKey) { 'Yes' } else { 'No' } - Severity = $severity - Status = $status - }) - } - return $out -} - -function Resolve-IntuneLapsAnomalies { - <# - For each Windows managed device, decide whether an Entra-backed Windows LAPS - policy is assigned and whether a local admin credential is actually backed up. - A backup older than -MaxBackupAgeDays is reported as a rotation gap. - - Devices that were explicitly excluded (exclude group or assignment filter) are - suppressed by default; pass -ShowExcludedDevices to surface them as Info rows. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] [array] $Devices, - [Parameter(Mandatory)] [PSCustomObject] $Context, - [Parameter(Mandatory)] [string] $TenantName, - [int] $MaxBackupAgeDays = 60, - [switch] $ShowExcludedDevices - ) - $out = [System.Collections.Generic.List[PSObject]]::new() - $cutoff = (Get-Date).AddDays(-$MaxBackupAgeDays) - - foreach ($d in $Devices) { - $aadId = Resolve-DeviceAzureAdDeviceId -Device $d -Context $Context - if (-not $aadId) { continue } - $eval = Get-DeviceAppliedPolicies -Policies $Context.LapsPolicies -Device $d -Context $Context - $applied = $eval.Applied - $excluded = $eval.Excluded - - # Primary join: LAPS credentials are keyed by the Azure AD device id. - # Secondary join (deviceName) covers the rare case where the device id we - # have differs from the one Entra stored the LAPS entry against. - $entry = $Context.LapsCredentialByDeviceId[$aadId] - if (-not $entry -and $d.DeviceName) { - $nameKey = ([string]$d.DeviceName).ToLowerInvariant() - if ($Context.LapsCredentialByDeviceName.ContainsKey($nameKey)) { - $entry = $Context.LapsCredentialByDeviceName[$nameKey] - } - } - - $status = $null; $severity = $null - $lastBackup = $null; $ageDays = $null - if ($entry -and $entry.lastBackupDateTime) { - try { $lastBackup = [datetime]$entry.lastBackupDateTime; $ageDays = [int](((Get-Date) - $lastBackup).TotalDays) } catch { } - } - - if ($applied.Count -eq 0 -and $excluded.Count -gt 0) { - if (-not $ShowExcludedDevices) { continue } - $reasons = ($excluded | Select-Object -ExpandProperty Reason -Unique) -join ', ' - $status = "Intentionally excluded from LAPS policies ($reasons)" - $severity = 'Info' - } - elseif ($applied.Count -eq 0) { $status = 'No Entra-backed LAPS policy assigned'; $severity = 'Warning' } - elseif (-not $entry) { $status = 'LAPS policy assigned but no credential backed up to Entra'; $severity = 'Critical' } - elseif ($lastBackup -and $lastBackup -lt $cutoff) { $status = "LAPS backup stale (> $MaxBackupAgeDays days, rotation may be stalled)"; $severity = 'Warning' } - else { continue } # Healthy. - - $out.Add([PSCustomObject][ordered]@{ - Customer = $TenantName - DeviceName = $d.DeviceName - PrimaryUser = $d.PrimaryUser - Serialnumber = $d.Serialnumber - OwnerType = $d.DeviceOwnership - AppliedPolicies = if ($applied.Count -gt 0) { ($applied | ForEach-Object { $_.PolicyName }) -join '; ' } else { ($excluded | ForEach-Object { "$($_.PolicyName) [$($_.Reason)]" }) -join '; ' } - LastBackupDateTime = if ($lastBackup) { $lastBackup.ToString('yyyy-MM-dd HH:mm') } else { '' } - BackupAgeDays = if ($null -ne $ageDays) { $ageDays } else { '' } - Severity = $severity - Status = $status - }) - } - return $out -} - -# ---------------------------------------------------------------------------- -# Deprecated Intune settings discovery. -# Walks every Settings Catalog policy already fetched into the context by -# Get-BitLockerLapsAssignmentContext and flags individual settings that -# Microsoft has marked as deprecated. Detection sources, in order of precedence: -# 1. Catalog DisplayName for the settingDefinitionId contains "deprecated" -# 2. The setting's raw definition id contains "deprecated" -# 3. The configured value string contains "deprecated" -# Catalog data comes from Get-RKIntuneSettingsCatalog (cached download from -# github.com/royklo/IntuneSettingsCatalogData); when the catalog is empty the -# walker still detects the subset that carries "deprecated" in the raw id or -# value string. -# ---------------------------------------------------------------------------- - -function Test-IsSettingDeprecated { - <# - Returns @{ IsDeprecated; Source; DisplayName } where Source is one of - 'CatalogDisplayName' | 'DefinitionIdMatch' | 'ValueMatch' on a hit. - #> - [CmdletBinding()] - param( - [string] $DefinitionId, - [string] $Value, - [hashtable]$Catalog - ) - - if ($Catalog -and $Catalog.Count -gt 0 -and -not [string]::IsNullOrEmpty($DefinitionId) -and $Catalog.ContainsKey($DefinitionId)) { - $dn = [string]$Catalog[$DefinitionId].DisplayName - if ($dn -match '(?i)deprecated') { - return [PSCustomObject]@{ IsDeprecated = $true; Source = 'CatalogDisplayName'; DisplayName = $dn } - } - } - if (-not [string]::IsNullOrEmpty($DefinitionId) -and $DefinitionId -match '(?i)deprecated') { - $dn = if ($Catalog -and $Catalog.ContainsKey($DefinitionId)) { [string]$Catalog[$DefinitionId].DisplayName } else { $DefinitionId } - return [PSCustomObject]@{ IsDeprecated = $true; Source = 'DefinitionIdMatch'; DisplayName = $dn } - } - if (-not [string]::IsNullOrEmpty($Value) -and $Value -match '(?i)deprecated') { - $dn = if ($Catalog -and $Catalog.ContainsKey($DefinitionId)) { [string]$Catalog[$DefinitionId].DisplayName } else { $DefinitionId } - return [PSCustomObject]@{ IsDeprecated = $true; Source = 'ValueMatch'; DisplayName = $dn } - } - return [PSCustomObject]@{ IsDeprecated = $false } -} - -function Get-IntuneSettingValueString { - <# - Extract the configured value(s) from a settingInstance, regardless of OData - sub-type (choice / simple / collection). Returns a string suitable for - display and for the deprecation regex check. Group / collection instances - return empty here; their children are walked separately by the caller. - #> - [CmdletBinding()] - [OutputType([string])] - param([Parameter(Mandatory)] $SettingInstance) - - $type = [string]$SettingInstance.'@odata.type' - switch -Wildcard ($type) { - '*choiceSettingInstance*' { return [string]$SettingInstance.choiceSettingValue.value } - '*simpleSettingInstance*' { return [string]$SettingInstance.simpleSettingValue.value } - '*choiceSettingCollectionInstance*' { - $vals = @() - foreach ($cv in @($SettingInstance.choiceSettingCollectionValue)) { if ($cv.value) { $vals += [string]$cv.value } } - return ($vals -join '; ') - } - '*simpleSettingCollectionInstance*' { - $vals = @() - foreach ($sv in @($SettingInstance.simpleSettingCollectionValue)) { if ($sv.value) { $vals += [string]$sv.value } } - return ($vals -join '; ') - } - default { return '' } - } -} - -function Get-IntuneSettingDeprecations { - <# - Recursively walk a settingInstance (and any nested group children) and - return one record per deprecated setting found: - { DefinitionId; DisplayName; Value; Source } - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] $SettingInstance, - [hashtable] $Catalog - ) - - $hits = [System.Collections.Generic.List[object]]::new() - if (-not $SettingInstance) { return $hits } - - $defId = [string]$SettingInstance.settingDefinitionId - $value = Get-IntuneSettingValueString -SettingInstance $SettingInstance - $check = Test-IsSettingDeprecated -DefinitionId $defId -Value $value -Catalog $Catalog - if ($check.IsDeprecated) { - $hits.Add([PSCustomObject]@{ - DefinitionId = $defId - DisplayName = if ([string]::IsNullOrWhiteSpace($check.DisplayName)) { $defId } else { $check.DisplayName } - Value = $value - Source = $check.Source - }) - } - - $type = [string]$SettingInstance.'@odata.type' - if ($type -like '*groupSettingCollectionInstance*') { - foreach ($child in @($SettingInstance.groupSettingCollectionValue)) { - foreach ($childSetting in @($child.children)) { - foreach ($h in (Get-IntuneSettingDeprecations -SettingInstance $childSetting -Catalog $Catalog)) { $hits.Add($h) } - } - } - } - elseif ($type -like '*groupSettingInstance*') { - foreach ($childSetting in @($SettingInstance.groupSettingValue.children)) { - foreach ($h in (Get-IntuneSettingDeprecations -SettingInstance $childSetting -Catalog $Catalog)) { $hits.Add($h) } - } - } - return $hits -} - -function Resolve-IntuneDeprecatedAnomalies { - <# - Iterates the Settings Catalog policies already on the context, scans - every setting against the cached settings-catalog lookup, and emits one - row per deprecated setting in use. Loads / refreshes the catalog on - first call; subsequent calls in the same session reuse it. - #> - [CmdletBinding()] - param( - [Parameter(Mandatory)] [PSCustomObject] $Context, - [Parameter(Mandatory)] [string] $TenantName - ) - - Write-Host ' Scanning Settings Catalog policies for deprecated settings...' -ForegroundColor Cyan - $catalog = Get-RKIntuneSettingsCatalog - $out = [System.Collections.Generic.List[PSObject]]::new() - - foreach ($policy in $Context.ConfigurationPolicies) { - if (-not $policy.settings) { continue } - $policyName = if ($policy.name) { $policy.name } elseif ($policy.displayName) { $policy.displayName } else { '(unnamed)' } - $platform = if ($policy.platforms) { [string]$policy.platforms } else { '' } - $technologies = if ($policy.technologies) { [string]$policy.technologies } else { '' } - - foreach ($s in @($policy.settings)) { - if (-not $s.settingInstance) { continue } - foreach ($hit in (Get-IntuneSettingDeprecations -SettingInstance $s.settingInstance -Catalog $catalog)) { - $out.Add([PSCustomObject]@{ - Customer = $TenantName - PolicyName = $policyName - PolicyId = $policy.id - Platform = $platform - Technologies = $technologies - SettingDisplayName = $hit.DisplayName - SettingDefinitionId = $hit.DefinitionId - ConfiguredValue = $hit.Value - DetectionSource = $hit.Source - }) - } - } - } - - Write-Host " Found $($out.Count) deprecated setting instance(s) across $($Context.ConfigurationPolicies.Count) Settings Catalog policies." -ForegroundColor Gray - return $out -} From 4c844e2387d736cf0a6a373312740695d641f817 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 25 Oct 2026 22:28:46 +0100 Subject: [PATCH 06/24] fix(core): retry transient Graph errors, honour -ThrowOnError in the app policy lookup Co-Authored-By: Claude Sonnet 5.5 --- Tests/IntuneAnomalyCore.Tests.ps1 | 61 ++++++++++++++++++++++++++ module/Core/IntuneAnomalyCollector.ps1 | 6 +-- module/Core/IntuneAnomalyResolvers.ps1 | 2 +- module/Core/IntunePolicyAssignment.ps1 | 5 ++- module/Private/RKCoreGraphAdapter.ps1 | 21 +++++++-- module/RKSolutions.psm1 | 4 +- 6 files changed, 89 insertions(+), 10 deletions(-) diff --git a/Tests/IntuneAnomalyCore.Tests.ps1 b/Tests/IntuneAnomalyCore.Tests.ps1 index 268c8b5..5815c96 100644 --- a/Tests/IntuneAnomalyCore.Tests.ps1 +++ b/Tests/IntuneAnomalyCore.Tests.ps1 @@ -87,6 +87,34 @@ Describe 'Module adapters' { (Invoke-RKCoreGraphRequest -Uri 'beta/x').'@odata.nextLink' | Should -Be 'n' } } + It 'Invoke-RKCoreGraphRequest retries a 502 once and returns the next result' { + InModuleScope RKSolutions { + $script:calls = 0 + Mock Start-Sleep { } + Mock Invoke-MgGraphRequest { + $script:calls++ + if ($script:calls -eq 1) { + $e = [System.Exception]::new('bad gateway') + $e | Add-Member -NotePropertyName Response -NotePropertyValue ([pscustomobject]@{ StatusCode = 502 }) + throw $e + } + [pscustomobject]@{ value = @(1) } + } + @(Invoke-RKCoreGraphRequest -Uri 'beta/x' -All) | Should -Be @(1) + } + } + It 'Invoke-RKCoreGraphRequest does not retry a 403' { + InModuleScope RKSolutions { + Mock Start-Sleep { } + Mock Invoke-MgGraphRequest { + $e = [System.Exception]::new('forbidden') + $e | Add-Member -NotePropertyName Response -NotePropertyValue ([pscustomobject]@{ StatusCode = 403 }) + throw $e + } + { Invoke-RKCoreGraphRequest -Uri 'beta/x' -All } | Should -Throw + Should -Invoke Invoke-MgGraphRequest -Times 1 -Exactly + } + } It 'Invoke-RKCoreGraphBatch groups requests by version' { InModuleScope RKSolutions { Mock Invoke-RKGraphBatch { foreach ($r in $Requests) { [pscustomobject]@{ Id = $r.Id; Status = 200; Body = "$GraphVersion$($r.Url)" } } } @@ -99,3 +127,36 @@ Describe 'Module adapters' { } } } + +Describe 'Get-DetailedPolicyAssignments -ThrowOnError' { + It 'returns an empty result for a 404' { + InModuleScope RKSolutions { + Mock Invoke-RKCoreGraphRequest { + $e = [System.Exception]::new('http 404') + $e | Add-Member -NotePropertyName Response -NotePropertyValue ([pscustomobject]@{ StatusCode = 404 }) + throw $e + } + @(Get-DetailedPolicyAssignments -EntityType 'configurationPolicies' -EntityId 'p1' -PolicyName 'P' -ThrowOnError).Count | Should -Be 0 + } + } + It 'throws for a 500' { + InModuleScope RKSolutions { + Mock Invoke-RKCoreGraphRequest { + $e = [System.Exception]::new('http 500') + $e | Add-Member -NotePropertyName Response -NotePropertyValue ([pscustomobject]@{ StatusCode = 500 }) + throw $e + } + { Get-DetailedPolicyAssignments -EntityType 'configurationPolicies' -EntityId 'p1' -PolicyName 'P' -ThrowOnError } | Should -Throw + } + } + It 'returns an empty result for a 500 without the switch' { + InModuleScope RKSolutions { + Mock Invoke-RKCoreGraphRequest { + $e = [System.Exception]::new('http 500') + $e | Add-Member -NotePropertyName Response -NotePropertyValue ([pscustomobject]@{ StatusCode = 500 }) + throw $e + } + @(Get-DetailedPolicyAssignments -EntityType 'configurationPolicies' -EntityId 'p1' -PolicyName 'P').Count | Should -Be 0 + } + } +} diff --git a/module/Core/IntuneAnomalyCollector.ps1 b/module/Core/IntuneAnomalyCollector.ps1 index 2615390..e82e1e5 100644 --- a/module/Core/IntuneAnomalyCollector.ps1 +++ b/module/Core/IntuneAnomalyCollector.ps1 @@ -535,9 +535,9 @@ function Get-AutopilotProfilesInformation { # backed) policy are actually applied (via assignment resolution incl. groups, # all users/devices, and assignment filters), and cross-references with the # recovery key / local credential directory endpoints to flag escrow gaps. -# Reuses Invoke-GraphRequestWithPaging, Get-DetailedPolicyAssignments, -# Test-IntuneFilter, and the $script:AllFilters cache established by -# IntuneEnrollmentFlows.ps1 - load order is enforced in RKSolutions.psm1. +# Reuses Invoke-GraphRequestWithPaging, the $script:AllFilters cache established by +# IntuneEnrollmentFlows.ps1 (load order is enforced in RKSolutions.psm1), and +# Get-DetailedPolicyAssignments and Test-IntuneFilter from Core/IntunePolicyAssignment.ps1. # ---------------------------------------------------------------------------- function Get-BitLockerLapsAssignmentContext { diff --git a/module/Core/IntuneAnomalyResolvers.ps1 b/module/Core/IntuneAnomalyResolvers.ps1 index 26076cc..248118c 100644 --- a/module/Core/IntuneAnomalyResolvers.ps1 +++ b/module/Core/IntuneAnomalyResolvers.ps1 @@ -51,7 +51,7 @@ function Test-AssignmentAppliesToManagedDevice { Evaluates a single Get-DetailedPolicyAssignments row against a managed device record using the pre-resolved group membership sets in the context. Returns @{ Applies = $bool; FilterDecision = '...' }. Assignment filters are honored - via Test-IntuneFilter (provided by IntuneEnrollmentFlows.ps1). + via Test-IntuneFilter (provided by Core/IntunePolicyAssignment.ps1). #> param( [Parameter(Mandatory)] [PSCustomObject] $Assignment, diff --git a/module/Core/IntunePolicyAssignment.ps1 b/module/Core/IntunePolicyAssignment.ps1 index ab0046c..8ac7ce9 100644 --- a/module/Core/IntunePolicyAssignment.ps1 +++ b/module/Core/IntunePolicyAssignment.ps1 @@ -28,7 +28,10 @@ function Get-DetailedPolicyAssignments { } if ($path) { $assignmentsUri = "beta/deviceAppManagement/$path('$EntityId')/assignments" } } - catch { return @() } + catch { + if ($ThrowOnError -and (Get-RKCoreGraphErrorStatus -ErrorRecord $_) -ne 404) { throw } + return @() + } } elseif ($EntityType -eq "mobileAppConfigurations") { $assignmentsUri = "beta/deviceAppManagement/mobileAppConfigurations('$EntityId')/assignments" diff --git a/module/Private/RKCoreGraphAdapter.ps1 b/module/Private/RKCoreGraphAdapter.ps1 index 7852ed9..3ae3a2b 100644 --- a/module/Private/RKCoreGraphAdapter.ps1 +++ b/module/Private/RKCoreGraphAdapter.ps1 @@ -1,6 +1,21 @@ # Private: the module's implementation of the two Graph adapters module/Core calls. # The RKSolutions Maester pack has its own implementation (helpers/GraphAdapter.ps1); this file is not synced. +function Invoke-RKCoreGraphGet { + # One GET. The Graph SDK retries 429/503/504 itself; retry 500 and 502 here like Maester's own Graph helper. + [CmdletBinding()] + param([Parameter(Mandatory)] [string] $Uri) + + $delays = @(2, 4) + for ($attempt = 0; ; $attempt++) { + try { return Invoke-MgGraphRequest -Method GET -Uri $Uri -OutputType PSObject -ErrorAction Stop } + catch { + if ($attempt -ge $delays.Count -or (Get-RKCoreGraphErrorStatus -ErrorRecord $_) -notin 500, 502) { throw } + Start-Sleep -Seconds $delays[$attempt] + } + } +} + function Invoke-RKCoreGraphRequest { <# .SYNOPSIS @@ -15,14 +30,14 @@ function Invoke-RKCoreGraphRequest { $target = ConvertTo-RKCoreGraphPath -Uri $Uri $next = "https://graph.microsoft.com/$($target.Version)/$($target.Path)" - if (-not $All) { return Invoke-MgGraphRequest -Method GET -Uri $next -OutputType PSObject -ErrorAction Stop } + if (-not $All) { return Invoke-RKCoreGraphGet -Uri $next } $items = [System.Collections.Generic.List[object]]::new() $seen = [System.Collections.Generic.HashSet[string]]::new() while ($next) { if (-not $seen.Add($next)) { throw "Graph returned the same @odata.nextLink twice for $Uri." } - $page = Invoke-MgGraphRequest -Method GET -Uri $next -OutputType PSObject -ErrorAction Stop - if ($page.PSObject.Properties['value']) { foreach ($item in @($page.value)) { $items.Add($item) } } + $page = Invoke-RKCoreGraphGet -Uri $next + if ($page.PSObject.Properties['value']) { foreach ($item in @($page.value | Where-Object { $null -ne $_ })) { $items.Add($item) } } else { $items.Add($page) } $next = $page.'@odata.nextLink' } diff --git a/module/RKSolutions.psm1 b/module/RKSolutions.psm1 index ce378f9..e3bfdc0 100644 --- a/module/RKSolutions.psm1 +++ b/module/RKSolutions.psm1 @@ -41,8 +41,8 @@ if (Test-Path $privatePath) { if (Test-Path $fp) { . $fp } } # Report-specific private scripts. IntuneAnomalies leans on helpers - # (Get-DetailedPolicyAssignments, Test-IntuneFilter, $script:AllFilters) defined - # in IntuneEnrollmentFlows, so it must be loaded after it. + # ($script:AllFilters) defined in IntuneEnrollmentFlows, so it must be loaded after it. + # Get-DetailedPolicyAssignments and Test-IntuneFilter live in Core/IntunePolicyAssignment.ps1. $domainOrder = @('IntuneEnrollmentFlows.ps1', 'IntuneAnomalies.ps1', 'EntraAdminRoles.ps1', 'M365License.ps1', 'CustomSecurityAttributes.ps1') foreach ($name in $domainOrder) { $fp = Join-Path $privatePath $name From 7a75cb03ad7e0a1acca1ae6638b5fa66e1c8493b Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 25 Oct 2026 22:40:18 +0100 Subject: [PATCH 07/24] feat(core): collect through the core adapters, GET only, with required, scope-gated and best-effort reads Co-Authored-By: Claude Opus 5.5 --- Tests/IntuneAnomalyCore.Tests.ps1 | 85 ++++ module/Core/IntuneAnomalyCollector.ps1 | 529 +++++++++++-------------- 2 files changed, 324 insertions(+), 290 deletions(-) diff --git a/Tests/IntuneAnomalyCore.Tests.ps1 b/Tests/IntuneAnomalyCore.Tests.ps1 index 5815c96..ec81a71 100644 --- a/Tests/IntuneAnomalyCore.Tests.ps1 +++ b/Tests/IntuneAnomalyCore.Tests.ps1 @@ -160,3 +160,88 @@ Describe 'Get-DetailedPolicyAssignments -ThrowOnError' { } } } + +Describe 'Collector error kinds' { + It 'Get-IntuneAppData reports an app list it may not read (403) as not readable' { + InModuleScope RKSolutions { + Mock Invoke-RKCoreGraphRequest -ParameterFilter { $Uri -like 'beta/deviceAppManagement/mobileApps*' } { + $e = [System.Exception]::new('http 403') + $e | Add-Member -NotePropertyName Response -NotePropertyValue ([pscustomobject]@{ StatusCode = 403 }) + throw $e + } + $w = [System.Collections.Generic.List[object]]::new() + $r = Get-IntuneAppData -TenantName 'T' -Warnings $w + $r.Readable | Should -BeFalse + $r.Error | Should -Be 'http 403' + @($r.Apps).Count | Should -Be 0 + } + } + It 'Get-IntuneAppData computes the failed percentage and leaves out an app whose summary answers 400' { + InModuleScope RKSolutions { + Mock Invoke-RKCoreGraphRequest -ParameterFilter { $Uri -like 'beta/deviceAppManagement/mobileApps*' } { + [pscustomobject]@{ id = 'a1'; displayName = 'App 1'; displayVersion = '1.0'; isAssigned = $true } + [pscustomobject]@{ id = 'a2'; displayName = 'App 2'; displayVersion = '2.0'; isAssigned = $false } + } + Mock Invoke-RKCoreGraphBatch -ParameterFilter { @($Requests.Url) -like '*/installSummary' } { + [pscustomobject]@{ Id = 'a1'; Status = 200; Body = [pscustomobject]@{ failedDeviceCount = 1; installedDeviceCount = 3; notInstalledDeviceCount = 0; pendingInstallDeviceCount = 0; failedUserCount = 1 } } + [pscustomobject]@{ Id = 'a2'; Status = 400; Body = $null } + } + $w = [System.Collections.Generic.List[object]]::new() + $r = Get-IntuneAppData -TenantName 'T' -Warnings $w + $r.Readable | Should -BeTrue + @($r.Apps).Count | Should -Be 1 + $r.Apps[0].Application | Should -Be 'App 1' + $r.Apps[0].FailedDevicePercentage | Should -Be 25 + $w.Count | Should -Be 1 + $w[0].Area | Should -Be 'Apps' + $w[0].Message | Should -BeLike '*1 app(s)*' + } + } + It 'Get-BitLockerLapsAssignmentContext marks the BitLocker keys unreadable on 403 and still returns the context' { + InModuleScope RKSolutions { + Mock Invoke-RKCoreGraphRequest { } + Mock Invoke-RKCoreGraphBatch { } + Mock Invoke-RKCoreGraphRequest -ParameterFilter { $Uri -like 'v1.0/informationProtection/bitlocker/recoveryKeys*' } { + $e = [System.Exception]::new('http 403') + $e | Add-Member -NotePropertyName Response -NotePropertyValue ([pscustomobject]@{ StatusCode = 403 }) + throw $e + } + $w = [System.Collections.Generic.List[object]]::new() + $ctx = Get-BitLockerLapsAssignmentContext -Warnings $w + $ctx.BitLockerKeysReadable | Should -BeFalse + $ctx.BitLockerKeysError | Should -Be 'http 403' + $ctx.LapsCredentialsReadable | Should -BeTrue + } + } + It 'Get-BitLockerLapsAssignmentContext throws when the configurationPolicies list answers 500' { + InModuleScope RKSolutions { + Mock Invoke-RKCoreGraphRequest { } + Mock Invoke-RKCoreGraphBatch { } + Mock Invoke-RKCoreGraphRequest -ParameterFilter { $Uri -eq 'beta/deviceManagement/configurationPolicies' } { + $e = [System.Exception]::new('http 500') + $e | Add-Member -NotePropertyName Response -NotePropertyValue ([pscustomobject]@{ StatusCode = 500 }) + throw $e + } + $w = [System.Collections.Generic.List[object]]::new() + { Get-BitLockerLapsAssignmentContext -Warnings $w } | Should -Throw 'http 500' + } + } + It 'Get-BitLockerLapsAssignmentContext warns, and does not throw, when a group is not found (404)' { + InModuleScope RKSolutions { + Mock Invoke-RKCoreGraphRequest { } + Mock Invoke-RKCoreGraphBatch { } + Mock Invoke-RKCoreGraphRequest -ParameterFilter { $Uri -eq 'beta/deviceManagement/deviceConfigurations' } { + [pscustomobject]@{ id = 'c1'; displayName = 'BitLocker'; '@odata.type' = '#microsoft.graph.windows10EndpointProtectionConfiguration' } + } + Mock Get-DetailedPolicyAssignments { [pscustomobject]@{ GroupId = 'g1' } } + Mock Invoke-RKCoreGraphBatch -ParameterFilter { @($Requests.Url) -like '*/transitiveMembers/*' } { + [pscustomobject]@{ Id = 'gd:g1'; Status = 404; Body = $null } + [pscustomobject]@{ Id = 'gu:g1'; Status = 200; Body = [pscustomobject]@{ value = @([pscustomobject]@{ id = 'u1'; userPrincipalName = 'User@contoso.com' }) } } + } + $w = [System.Collections.Generic.List[object]]::new() + $ctx = Get-BitLockerLapsAssignmentContext -Warnings $w + @($w | Where-Object { $_.Area -eq 'BitLocker' -and $_.Message -like '*group g1*' }).Count | Should -Be 1 + $ctx.GroupUserUpns['g1'].Contains('user@contoso.com') | Should -BeTrue + } + } +} diff --git a/module/Core/IntuneAnomalyCollector.ps1 b/module/Core/IntuneAnomalyCollector.ps1 index e82e1e5..03370e9 100644 --- a/module/Core/IntuneAnomalyCollector.ps1 +++ b/module/Core/IntuneAnomalyCollector.ps1 @@ -1,4 +1,12 @@ function Get-AllDeviceData { + [CmdletBinding()] + param( + # Entra member users (id, userPrincipalName), for the logged-on users lookup. + [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Users, + [Parameter(Mandatory)] [string] $TenantName, + [Parameter(Mandatory)] [AllowEmptyCollection()] [System.Collections.Generic.List[object]] $Warnings + ) + function Get-OperatingSystemProductType { param ( $Customer @@ -107,49 +115,55 @@ function Get-AllDeviceData { # Get all Windows Devices from Microsoft Intune $swStep = [System.Diagnostics.Stopwatch]::StartNew() - $AllDeviceData = Invoke-graphRequestWithPaging -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=operatingSystem eq 'Windows'&`$select=$($Properties -join ',')" + $AllDeviceData = @(Invoke-RKCoreGraphRequest -Uri "beta/deviceManagement/managedDevices?`$filter=operatingSystem eq 'Windows'&`$select=$($Properties -join ',')" -All) #filter out managed by MDE $AllDeviceData = $AllDeviceData | Where-Object { $_.managementAgent -ne "msSense" } $swStep.Stop() Write-Verbose ("[Get-AllDeviceData] Managed devices fetch: {0:N2}s ({1} devices)" -f $swStep.Elapsed.TotalSeconds, $AllDeviceData.Count) - # The list endpoint always reports deviceType 'windowsRT'; only a per-device GET returns 'cloudPC' - # (Windows 365 and Dev Box). A failed lookup counts as a physical device so nothing is hidden by mistake. - $swStep.Restart() - $CloudPcIds = [System.Collections.Generic.HashSet[string]]::new() - $deviceTypeRequests = foreach ($d in $AllDeviceData) { - [PSCustomObject]@{ Id = "dt:$($d.id)"; Url = "/deviceManagement/managedDevices/$($d.id)?`$select=id,deviceType" } - } - foreach ($resp in (Invoke-RKGraphBatch -Requests @($deviceTypeRequests) -Activity 'Device types')) { - if ($resp.Status -eq 200 -and $resp.Body.deviceType -eq 'cloudPC') { [void]$CloudPcIds.Add(($resp.Id -replace '^dt:', '')) } - } - $swStep.Stop() - Write-Verbose ("[Get-AllDeviceData] Device type lookup: {0:N2}s ({1} Cloud PCs)" -f $swStep.Elapsed.TotalSeconds, $CloudPcIds.Count) - # Get all AutoPilot registered devices under "Enrollment" - Write-Host "Fetching Autopilot devices..." -ForegroundColor Yellow + Write-Verbose "Fetching Autopilot devices..." $swStep.Restart() - $AutopilotDevices = (Invoke-GraphRequestWithPaging -Uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities") + $AutopilotDevices = @(Invoke-RKCoreGraphRequest -Uri 'beta/deviceManagement/windowsAutopilotDeviceIdentities' -All) $swStep.Stop() Write-Verbose ("[Get-AllDeviceData] Autopilot devices fetch: {0:N2}s ({1} devices)" -f $swStep.Elapsed.TotalSeconds, $AutopilotDevices.Count) # Pre-build Autopilot lookup hashtable (serialNumber -> device object) for O(1) lookups $AutopilotLookup = @{} foreach ($ap in $AutopilotDevices) { - if ($ap.serialNumber) { $AutopilotLookup[$ap.serialNumber] = $ap } + if ($ap.serialNumber) { $AutopilotLookup[[string]$ap.serialNumber] = $ap } } # Autopilot device preparation (v2) associates devices via corporate identifiers # ("Manufacturer,Model,Serial" or a bare serial) instead of a hardware hash. $CorporateIdentifierSerials = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - $CorporateIdentifiers = Invoke-GraphRequestWithPaging -Uri "https://graph.microsoft.com/beta/deviceManagement/importedDeviceIdentities" + $CorporateIdentifiers = @(Invoke-RKCoreGraphRequest -Uri 'beta/deviceManagement/importedDeviceIdentities' -All) foreach ($ci in $CorporateIdentifiers) { if ($ci.importedDeviceIdentifier) { [void]$CorporateIdentifierSerials.Add(($ci.importedDeviceIdentifier -split ',')[-1].Trim()) } } + # Only devices that BitLocker or Autopilot would flag need their deviceType: the list endpoint always reports + # 'windowsRT', and only a per-device GET returns 'cloudPC' (Windows 365 and Dev Box). + $swStep.Restart() + $candidates = @($AllDeviceData | Where-Object { + -not $_.isEncrypted -or (-not $AutopilotLookup.ContainsKey([string]$_.serialNumber) -and -not ($_.serialNumber -and $CorporateIdentifierSerials.Contains([string]$_.serialNumber))) + }) + $CloudPcIds = [System.Collections.Generic.HashSet[string]]::new() + $deviceTypeRequests = @(foreach ($d in $candidates) { [pscustomobject]@{ Id = "dt:$($d.id)"; Url = "beta/deviceManagement/managedDevices/$($d.id)?`$select=id,deviceType" } }) + $failedLookups = 0 + foreach ($resp in @(Invoke-RKCoreGraphBatch -Requests $deviceTypeRequests)) { + if ($resp.Status -ne 200) { $failedLookups++; continue } + if ($resp.Body.deviceType -eq 'cloudPC') { [void]$CloudPcIds.Add(($resp.Id -replace '^dt:', '')) } + } + if ($failedLookups -gt 0) { + foreach ($area in 'BitLocker', 'Autopilot') { $Warnings.Add([pscustomobject]@{ Area = $area; Message = "The device type of $failedLookups device(s) could not be read; they are treated as physical devices, so a Cloud PC among them can be listed." }) } + } + $swStep.Stop() + Write-Verbose ("[Get-AllDeviceData] Device type lookup: {0:N2}s ({1} candidates, {2} Cloud PCs)" -f $swStep.Elapsed.TotalSeconds, $candidates.Count, $CloudPcIds.Count) + # Pre-build user lookup hashtable (id -> UPN) for O(1) lookups $UserLookup = @{} - foreach ($u in $AllEntraIDUsers) { + foreach ($u in $Users) { if ($u.id -and $u.userPrincipalName) { $UserLookup[$u.id] = $u.userPrincipalName } } @@ -162,22 +176,22 @@ function Get-AllDeviceData { # Module-scoped map: reset so a previous tenant's day count never leaks into this report. $script:ComplianceSettingFriendlyNames['RequireRemainContact'] = 'Is active (no compliance check-in within the compliance status validity period)' try { - $checkinDays = (Invoke-MgGraphRequest -Uri 'https://graph.microsoft.com/beta/deviceManagement/settings' -OutputType PSObject -ErrorAction Stop).deviceComplianceCheckinThresholdDays + $checkinDays = (Invoke-RKCoreGraphRequest -Uri 'beta/deviceManagement/settings').deviceComplianceCheckinThresholdDays if ($checkinDays) { $script:ComplianceSettingFriendlyNames['RequireRemainContact'] = "Is active (no compliance check-in in the last $checkinDays days)" } } catch { - Write-Verbose "Compliance validity period lookup failed: $($_.Exception.Message)" + $Warnings.Add([pscustomobject]@{ Area = 'Compliance'; Message = "The compliance status validity period could not be read, so its reason does not show the number of days: $($_.Exception.Message)" }) } - Write-Host "Fetching compliance details for $($NonCompliantDevices.Count) noncompliant devices..." -ForegroundColor Yellow + Write-Verbose "Fetching compliance details for $($NonCompliantDevices.Count) noncompliant devices..." $policyStateRequests = foreach ($d in $NonCompliantDevices) { [PSCustomObject]@{ Id = "ps:$($d.id)" - Url = "/deviceManagement/managedDevices/$($d.id)/deviceCompliancePolicyStates" + Url = "beta/deviceManagement/managedDevices/$($d.id)/deviceCompliancePolicyStates" } } $swStep.Restart() - $policyStateResponses = Invoke-RKGraphBatch -Requests @($policyStateRequests) -Activity "Compliance policy states" + $policyStateResponses = Invoke-RKCoreGraphBatch -Requests @($policyStateRequests) $swStep.Stop() Write-Verbose ("[Get-AllDeviceData] Compliance policy-states batch: {0:N2}s ({1} devices, {2} responses)" -f $swStep.Elapsed.TotalSeconds, $NonCompliantDevices.Count, $policyStateResponses.Count) @@ -204,14 +218,14 @@ function Get-AllDeviceData { foreach ($s in $states) { $settingStatePairs.Add([PSCustomObject]@{ Id = "ss:$deviceId|$($s.id)" - Url = "/deviceManagement/managedDevices/$deviceId/deviceCompliancePolicyStates/$($s.id)/settingStates" + Url = "beta/deviceManagement/managedDevices/$deviceId/deviceCompliancePolicyStates/$($s.id)/settingStates" }) } } if ($settingStatePairs.Count -gt 0) { $swStep.Restart() - $settingResponses = Invoke-RKGraphBatch -Requests @($settingStatePairs) -Activity "Compliance setting states" + $settingResponses = Invoke-RKCoreGraphBatch -Requests @($settingStatePairs) $swStep.Stop() Write-Verbose ("[Get-AllDeviceData] Compliance setting-states batch: {0:N2}s ({1} pairs)" -f $swStep.Elapsed.TotalSeconds, $settingStatePairs.Count) @@ -243,7 +257,7 @@ function Get-AllDeviceData { $deviceId = ($pair.Id -replace '^ss:', '') -split '\|' | Select-Object -First 1 if (-not $ComplianceRulesByDevice.ContainsKey($deviceId)) { continue } try { - $direct = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta$($pair.Url)" -ErrorAction Stop + $direct = Invoke-RKCoreGraphRequest -Uri $pair.Url $details = @($direct.value | Where-Object { $_.state -match 'nonCompliant' }) foreach ($det in $details) { $rule = if ($det.settingName) { $det.settingName } else { $det.setting } @@ -257,24 +271,21 @@ function Get-AllDeviceData { Write-Verbose ("[Get-AllDeviceData] Compliance setting-states fallback: {0:N2}s ({1} pairs retried individually)" -f $swStep.Elapsed.TotalSeconds, $failedPairs.Count) } } + + # Best effort: a noncompliant device without rules shows as Unknown on the Noncompliant tab, so count them. + $devicesWithoutRules = @($NonCompliantDevices | Where-Object { -not ($ComplianceRulesByDevice.ContainsKey($_.id) -and $ComplianceRulesByDevice[$_.id].Count -gt 0) }).Count + if ($devicesWithoutRules -gt 0) { $Warnings.Add([pscustomobject]@{ Area = 'Compliance'; Message = "The compliance reasons of $devicesWithoutRules device(s) could not be read; they show as Unknown." }) } } # Loop through all devices for device data $results = [System.Collections.Generic.List[PSObject]]::new() $totalDevices = $AllDeviceData.Count - Write-Host "Processing $totalDevices devices..." -ForegroundColor Yellow + Write-Verbose "Processing $totalDevices devices..." $swStep.Restart() for ($i = 0; $i -lt $AllDeviceData.Count; $i++) { $DeviceData = $AllDeviceData[$i] - $currentIndex = $i + 1 - - # Calculate progress percentage - $progressPercent = [math]::Round(($currentIndex / $totalDevices) * 100, 1) - - # Show progress bar instead of Write-Host - Write-Progress -Activity "Processing Intune Devices" -Status "Processing device: $($DeviceData.DeviceName)" -CurrentOperation "$currentIndex of $totalDevices devices processed" -PercentComplete $progressPercent try { # Use bulk-fetched data directly (no per-device re-fetch needed - same $select) @@ -374,159 +385,74 @@ function Get-AllDeviceData { } } - # Clear the progress bar when done - Write-Progress -Activity "Processing Intune Devices" -Completed $swStep.Stop() Write-Verbose ("[Get-AllDeviceData] Per-device projection loop: {0:N2}s" -f $swStep.Elapsed.TotalSeconds) $swTotal.Stop() Write-Verbose ("[Get-AllDeviceData] TOTAL: {0:N2}s" -f $swTotal.Elapsed.TotalSeconds) - Write-Host "Device processing completed!" -ForegroundColor Green - Write-Host "Processed $($results.Count) devices out of $totalDevices total devices" -ForegroundColor Green - - # Debug output for hardware information availability - $devicesWithoutHardwareInfo = $results | Where-Object { $_.TPMversion -eq "Unknown" } - - if ($devicesWithoutHardwareInfo.Count -gt 0) { - Write-Host "Some devices are missing hardware information:" -ForegroundColor Yellow - foreach ($device in $devicesWithoutHardwareInfo) { - Write-Host " - $($device.DeviceName) (Serial: $($device.Serialnumber))" -ForegroundColor Gray - } - } + Write-Verbose "Device processing completed!" + Write-Verbose "Processed $($results.Count) devices out of $totalDevices total devices" return $results } -function Get-ApplicationFailures { - - # Cross-platform temporary file path - # Detect OS and set appropriate temp path - $detectedWindows = $false - - # Check if automatic variables exist (PowerShell Core 6.0+) - if (Get-Variable -Name "IsWindows" -ErrorAction SilentlyContinue) { - $detectedWindows = $IsWindows - } - # Fallback for older PowerShell versions - else { - $osInfo = [System.Environment]::OSVersion.Platform - switch ($osInfo) { - "Win32NT" { $detectedWindows = $true } - "Unix" { - # macOS/Linux detected but not used in this context - } - default { - try { - if ([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([System.Runtime.InteropServices.OSPlatform]::Windows)) { - $detectedWindows = $true - } - else { - # Non-Windows OS detected - } - } catch { - $detectedWindows = $true - } - } - } - } +function Get-IntuneAppData { + <# + .SYNOPSIS + The Windows apps of the tenant with their install summary. Readable is $false (with Error) when the app list + cannot be read for lack of permission (DeviceManagementApps.Read.All). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $TenantName, + [Parameter(Mandatory)] [AllowEmptyCollection()] [System.Collections.Generic.List[object]] $Warnings + ) - # Use a unique temporary file to avoid race conditions - $Data = [System.IO.Path]::GetTempFileName() - - $apps = (Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?`$filter=(isof(%27microsoft.graph.win32CatalogApp%27)%20or%20isof(%27microsoft.graph.windowsStoreApp%27)%20or%20isof(%27microsoft.graph.microsoftStoreForBusinessApp%27)%20or%20isof(%27microsoft.graph.officeSuiteApp%27)%20or%20(isof(%27microsoft.graph.win32LobApp%27)%20and%20not(isof(%27microsoft.graph.win32CatalogApp%27)))%20or%20isof(%27microsoft.graph.windowsMicrosoftEdgeApp%27)%20or%20isof(%27microsoft.graph.windowsPhone81AppX%27)%20or%20isof(%27microsoft.graph.windowsPhone81StoreApp%27)%20or%20isof(%27microsoft.graph.windowsPhoneXAP%27)%20or%20isof(%27microsoft.graph.windowsAppX%27)%20or%20isof(%27microsoft.graph.windowsMobileMSI%27)%20or%20isof(%27microsoft.graph.windowsUniversalAppX%27)%20or%20isof(%27microsoft.graph.webApp%27)%20or%20isof(%27microsoft.graph.windowsWebApp%27)%20or%20isof(%27microsoft.graph.winGetApp%27))%20and%20(microsoft.graph.managedApp/appAvailability%20eq%20null%20or%20microsoft.graph.managedApp/appAvailability%20eq%20%27lineOfBusiness%27%20or%20isAssigned%20eq%20true)&`$orderby=displayName&").value - - $params = @{ - Select = @( - "DisplayName" - "Publisher" - "Platform" - "AppVersion" - "FailedDevicePercentage" - "FailedDeviceCount" - "FailedUserCount" - "ApplicationId" - ) - Skip = 0 - Top = 50 - Filter = "(FailedDeviceCount gt '0')" - OrderBy = @( - "FailedDeviceCount desc" - ) - } - Invoke-MgGraphRequest -Body $params -Uri "https://graph.microsoft.com/beta/deviceManagement/reports/getAppsInstallSummaryReport" -Method POST -OutputFilePath $Data - - $DataFile = Get-Content $Data - # Fix char encoding to UTF-8 - $Response = [system.Text.Encoding]::UTF8.GetString(($DataFile).ToCharArray()) | ConvertFrom-Json - - # Pre-build app lookup (id -> app) so the per-row joins are O(1) instead of O(N) Where-Object scans. - $appLookup = @{} - foreach ($a in $apps) { if ($a.Id) { $appLookup[[string]$a.Id] = $a } } - - # First pass: materialise the schema-flattened rows that have a matching app, - # collecting unique ApplicationIds so we can batch-fetch their assignments. - $rows = [System.Collections.Generic.List[hashtable]]::new() - $expandIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) - foreach ($value in $Response.Values) { - $LineObject = @{ } - foreach ($prop in $Response.Schema) { - $LineObject[$prop.Column] = $value[$Response.Schema.IndexOf($prop)] - } - $appId = [string]$LineObject.ApplicationId - if ($appLookup.ContainsKey($appId)) { - $rows.Add($LineObject) - [void]$expandIds.Add($appId) - } + $types = 'win32CatalogApp', 'windowsStoreApp', 'microsoftStoreForBusinessApp', 'officeSuiteApp', 'windowsMicrosoftEdgeApp', + 'windowsPhone81AppX', 'windowsPhone81StoreApp', 'windowsPhoneXAP', 'windowsAppX', 'windowsMobileMSI', 'windowsUniversalAppX', + 'webApp', 'windowsWebApp', 'winGetApp' + $typeFilter = (@($types | ForEach-Object { "isof('microsoft.graph.$_')" }) + "(isof('microsoft.graph.win32LobApp') and not(isof('microsoft.graph.win32CatalogApp')))") -join ' or ' + $filter = "($typeFilter) and (microsoft.graph.managedApp/appAvailability eq null or microsoft.graph.managedApp/appAvailability eq 'lineOfBusiness' or isAssigned eq true)" + try { + $apps = @(Invoke-RKCoreGraphRequest -Uri "beta/deviceAppManagement/mobileApps?`$filter=$filter&`$orderby=displayName" -All) } - - # Batch-fetch ?$expand=assignments for every failed app in parallel sub-requests. - $assignmentStatusByAppId = @{} - if ($expandIds.Count -gt 0) { - $expandRequests = foreach ($id in $expandIds) { - [PSCustomObject]@{ - Id = "app:$id" - Url = "/deviceAppManagement/mobileApps/$id/?`$expand=assignments" - } + catch { + if ((Get-RKCoreGraphErrorStatus -ErrorRecord $_) -in 401, 403) { + return [pscustomobject]@{ Readable = $false; Error = $_.Exception.Message; Apps = @() } } - $expandResponses = Invoke-RKGraphBatch -Requests @($expandRequests) -Activity 'App assignments' - foreach ($resp in $expandResponses) { - if ($resp.Status -ne 200 -or -not $resp.Body) { continue } - $appId = $resp.Id -replace '^app:', '' - $hasAssignments = $resp.Body.assignments -and (@($resp.Body.assignments).Count -gt 0) - $assignmentStatusByAppId[$appId] = [bool]$hasAssignments + throw + } + + $requests = @(foreach ($a in $apps) { [pscustomobject]@{ Id = [string]$a.id; Url = "beta/deviceAppManagement/mobileApps/$($a.id)/installSummary" } }) + $summaries = @{} + $unread = 0 + foreach ($resp in @(Invoke-RKCoreGraphBatch -Requests $requests)) { + # Per-app summaries are best effort: one app that answers 400 must not stop every test. + if ($resp.Status -eq 200 -and $resp.Body) { $summaries[$resp.Id] = $resp.Body } else { $unread++ } + } + if ($unread -gt 0) { $Warnings.Add([pscustomobject]@{ Area = 'Apps'; Message = "The install summary of $unread app(s) could not be read; they are left out." }) } + + $rows = foreach ($a in $apps) { + $s = $summaries[[string]$a.id] + if (-not $s) { continue } + $failed = [int]$s.failedDeviceCount + $targeted = $failed + [int]$s.installedDeviceCount + [int]$s.notInstalledDeviceCount + [int]$s.pendingInstallDeviceCount + [pscustomobject][ordered]@{ + Customer = $TenantName + Application = $a.displayName + Platform = 'Windows' + Version = [string]$a.displayVersion + AssignmentStatus = [bool]$a.isAssigned + FailedUserCount = [int]$s.failedUserCount + FailedDeviceCount = $failed + FailedDevicePercentage = if ($targeted -gt 0) { [math]::Round(100 * $failed / $targeted, 2) } else { 0 } } } - - # Build result array from the prepared rows using O(1) lookups. - $ReturnObject = New-Object System.Collections.ArrayList - foreach ($LineObject in $rows) { - $appId = [string]$LineObject.ApplicationId - $PlatformName = if ($LineObject.Platform_loc) { $LineObject.Platform_loc } else { $LineObject.Platform } - $AssignmentStatus = $false - if ($assignmentStatusByAppId.ContainsKey($appId)) { $AssignmentStatus = $assignmentStatusByAppId[$appId] } - - $ReturnObject.Add([PSCustomObject][ordered]@{ - Customer = $tenantname - Application = $appLookup[$appId].displayName - Platform = $PlatformName - Version = $LineObject.AppVersion - AssignmentStatus = $AssignmentStatus - FailedUserCount = $LineObject.FailedUserCount - FailedDeviceCount = $LineObject.FailedDeviceCount - FailedDevicePercentage = [math]::Round([double]$LineObject.FailedDevicePercentage, 2) - }) | Out-Null - } - - # Clean up temporary data file - if (Test-Path -Path $Data) { - Remove-Item -Path $Data -Force - } - - return $ReturnObject | Sort-Object -Property FailedDeviceCount -Descending + [pscustomobject]@{ Readable = $true; Error = $null; Apps = @($rows) } } function Get-AutopilotProfilesInformation { -(Invoke-GraphRequest -Uri "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/" -OutputType PSObject).value + @(Invoke-RKCoreGraphRequest -Uri 'beta/deviceManagement/windowsAutopilotDeploymentProfiles' -All) } # ---------------------------------------------------------------------------- @@ -535,8 +461,8 @@ function Get-AutopilotProfilesInformation { # backed) policy are actually applied (via assignment resolution incl. groups, # all users/devices, and assignment filters), and cross-references with the # recovery key / local credential directory endpoints to flag escrow gaps. -# Reuses Invoke-GraphRequestWithPaging, the $script:AllFilters cache established by -# IntuneEnrollmentFlows.ps1 (load order is enforced in RKSolutions.psm1), and +# Reads Graph through Invoke-RKCoreGraphRequest and Invoke-RKCoreGraphBatch, fills the +# $script:AllFilters cache that Initialize-RKIntuneCoreState creates, and uses # Get-DetailedPolicyAssignments and Test-IntuneFilter from Core/IntunePolicyAssignment.ps1. # ---------------------------------------------------------------------------- @@ -550,7 +476,8 @@ function Get-BitLockerLapsAssignmentContext { #> [CmdletBinding()] param( - [Parameter(Mandatory = $false)] [switch] $DebugMode + [Parameter(Mandatory = $false)] [switch] $DebugMode, + [Parameter(Mandatory)] [AllowEmptyCollection()] [System.Collections.Generic.List[object]] $Warnings ) $context = [PSCustomObject]@{ @@ -569,20 +496,19 @@ function Get-BitLockerLapsAssignmentContext { # the Entra device by displayName, recover its deviceId, retry the join. EntraDeviceIdByName = @{} # lowercased deviceName -> azureAdDeviceId Filters = @{} # filterId -> filter object (mirror of $script:AllFilters) - # Raw configurationPolicies?$expand=settings result, retained so the - # deprecation walker can iterate every Settings Catalog policy without - # a second Graph round-trip. + # configurationPolicies with their settings (and settingDefinitions) attached, + # retained so the deprecation walker can iterate every Settings Catalog policy + # without a second Graph round-trip. ConfigurationPolicies = @() + # Scope-gated reads: $false (with the error) when the connection lacks the scope. + BitLockerKeysReadable = $true + BitLockerKeysError = $null + LapsCredentialsReadable = $true + LapsCredentialsError = $null } # 1. Prime assignment filters cache (used by Get-DetailedPolicyAssignments + Test-IntuneFilter). - if ($script:AllFilters.Count -eq 0) { - try { - $rawFilters = Invoke-GraphRequestWithPaging -Uri 'https://graph.microsoft.com/beta/deviceManagement/assignmentFilters' - if ($rawFilters) { foreach ($f in $rawFilters) { $script:AllFilters[$f.id] = $f } } - } - catch { Write-Verbose "Failed to load assignment filters: $($_.Exception.Message)" } - } + foreach ($f in @(Invoke-RKCoreGraphRequest -Uri 'beta/deviceManagement/assignmentFilters' -All)) { $script:AllFilters[$f.id] = $f } $context.Filters = $script:AllFilters # 2. Discover Settings Catalog policies that contain BitLocker or LAPS settings. @@ -590,13 +516,9 @@ function Get-BitLockerLapsAssignmentContext { # /settings per policy. Avoids Graph hard-capping page size when $expand=settings is used # (observed ~1-2 policies per page on cosmos-backed tenants); batch sub-requests execute # server-side in parallel, so ~75 policies finish in ~4 batched round trips. - Write-Host ' Discovering Settings Catalog policies (BitLocker / LAPS)...' -ForegroundColor Cyan - $configPolicies = @() + Write-Verbose ' Discovering Settings Catalog policies (BitLocker / LAPS)...' $swCfg = [System.Diagnostics.Stopwatch]::StartNew() - try { - $configPolicies = Invoke-GraphRequestWithPaging -Uri 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?$top=999' - } - catch { Write-Warning "configurationPolicies list fetch failed: $($_.Exception.Message)" } + $configPolicies = @(Invoke-RKCoreGraphRequest -Uri 'beta/deviceManagement/configurationPolicies' -All) $swCfg.Stop() Write-Verbose ("[BitLockerLaps] configurationPolicies list: {0:N2}s ({1} policies)" -f $swCfg.Elapsed.TotalSeconds, $configPolicies.Count) @@ -605,15 +527,18 @@ function Get-BitLockerLapsAssignmentContext { $settingsRequests = foreach ($p in $configPolicies) { [PSCustomObject]@{ Id = "cps:$($p.id)" - Url = "/deviceManagement/configurationPolicies/$($p.id)/settings" + Url = "beta/deviceManagement/configurationPolicies/$($p.id)/settings?`$expand=settingDefinitions" } } - $settingsResponses = Invoke-RKGraphBatch -Requests @($settingsRequests) -Activity 'Settings Catalog policy settings' + $settingsResponses = Invoke-RKCoreGraphBatch -Requests @($settingsRequests) $settingsByPolicyId = @{} foreach ($resp in $settingsResponses) { - if ($resp.Status -ne 200 -or -not $resp.Body) { continue } - $pid = $resp.Id -replace '^cps:', '' - $settingsByPolicyId[$pid] = @($resp.Body.value) + $body = Get-RKCoreBatchBody -Response $resp -What "the settings of policy $($resp.Id -replace '^cps:', '')" -Area 'Settings' -Warnings $Warnings; if (-not $body) { continue } + $policyId = $resp.Id -replace '^cps:', '' + $settings = @($body.value) + # $expand can make Graph return smaller pages: read the rest, so a policy with many settings is complete. + if ($body.'@odata.nextLink') { $settings += @(Invoke-RKCoreGraphRequest -Uri $body.'@odata.nextLink' -All) } + $settingsByPolicyId[$policyId] = $settings } # Attach settings back onto each policy so the existing loop below stays unchanged. foreach ($p in $configPolicies) { @@ -652,7 +577,7 @@ function Get-BitLockerLapsAssignmentContext { } $displayName = if ($policy.name) { $policy.name } else { $policy.displayName } if ($hasBitLocker) { - $assignments = Get-DetailedPolicyAssignments -EntityType 'configurationPolicies' -EntityId $policy.id -PolicyName $displayName -DebugMode:$DebugMode + $assignments = Get-DetailedPolicyAssignments -EntityType 'configurationPolicies' -EntityId $policy.id -PolicyName $displayName -DebugMode:$DebugMode -ThrowOnError $context.BitLockerPolicies.Add([PSCustomObject]@{ Id = $policy.id DisplayName = $displayName @@ -662,7 +587,7 @@ function Get-BitLockerLapsAssignmentContext { } if ($hasLaps -and $lapsBackupValue -eq 1) { # Only Entra-backed LAPS policies are relevant to "can we read it from Entra?" - $assignments = Get-DetailedPolicyAssignments -EntityType 'configurationPolicies' -EntityId $policy.id -PolicyName $displayName -DebugMode:$DebugMode + $assignments = Get-DetailedPolicyAssignments -EntityType 'configurationPolicies' -EntityId $policy.id -PolicyName $displayName -DebugMode:$DebugMode -ThrowOnError $context.LapsPolicies.Add([PSCustomObject]@{ Id = $policy.id DisplayName = $displayName @@ -674,43 +599,40 @@ function Get-BitLockerLapsAssignmentContext { } # 3. Discover legacy Endpoint Protection device configurations that include BitLocker settings. - Write-Host ' Discovering legacy device configuration policies (BitLocker)...' -ForegroundColor Cyan - try { - $deviceConfigs = Invoke-GraphRequestWithPaging -Uri 'https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations' - foreach ($cfg in $deviceConfigs) { - $odataType = [string]$cfg.'@odata.type' - if ($odataType -eq '#microsoft.graph.windows10EndpointProtectionConfiguration') { - $assignments = Get-DetailedPolicyAssignments -EntityType 'deviceConfigurations' -EntityId $cfg.id -PolicyName $cfg.displayName -DebugMode:$DebugMode - $context.BitLockerPolicies.Add([PSCustomObject]@{ - Id = $cfg.id - DisplayName = $cfg.displayName - Source = 'DeviceConfiguration' - Assignments = @($assignments) - }) - } + Write-Verbose ' Discovering legacy device configuration policies (BitLocker)...' + $deviceConfigs = @(Invoke-RKCoreGraphRequest -Uri 'beta/deviceManagement/deviceConfigurations' -All) + foreach ($cfg in $deviceConfigs) { + $odataType = [string]$cfg.'@odata.type' + if ($odataType -eq '#microsoft.graph.windows10EndpointProtectionConfiguration') { + $assignments = Get-DetailedPolicyAssignments -EntityType 'deviceConfigurations' -EntityId $cfg.id -PolicyName $cfg.displayName -DebugMode:$DebugMode -ThrowOnError + $context.BitLockerPolicies.Add([PSCustomObject]@{ + Id = $cfg.id + DisplayName = $cfg.displayName + Source = 'DeviceConfiguration' + Assignments = @($assignments) + }) } } - catch { Write-Warning "deviceConfigurations fetch failed: $($_.Exception.Message)" } # 4. Discover Endpoint Security intents (BitLocker disk encryption template). - Write-Host ' Discovering Endpoint Security intents (BitLocker)...' -ForegroundColor Cyan - try { - $intents = Invoke-GraphRequestWithPaging -Uri 'https://graph.microsoft.com/beta/deviceManagement/intents' - foreach ($intent in $intents) { - $name = [string]$intent.displayName - # No reliable filter for BitLocker template; match by name as a pragmatic discovery hint. - if ($name -match '(?i)bitlocker' -or $name -match '(?i)disk\s*encryption') { - $assignments = Get-DetailedPolicyAssignments -EntityType 'deviceManagement/intents' -EntityId $intent.id -PolicyName $name -DebugMode:$DebugMode - $context.BitLockerPolicies.Add([PSCustomObject]@{ - Id = $intent.id - DisplayName = $name - Source = 'Intent' - Assignments = @($assignments) - }) - } + # The intent list is best effort; the assignments of a matching intent are read like every other policy's. + Write-Verbose ' Discovering Endpoint Security intents (BitLocker)...' + $intents = @() + try { $intents = @(Invoke-RKCoreGraphRequest -Uri 'beta/deviceManagement/intents' -All) } + catch { $Warnings.Add([pscustomobject]@{ Area = 'BitLocker'; Message = "The Endpoint Security intents could not be read, so a BitLocker policy made from an intent is not counted: $($_.Exception.Message)" }) } + foreach ($intent in $intents) { + $name = [string]$intent.displayName + # No reliable filter for BitLocker template; match by name as a pragmatic discovery hint. + if ($name -match '(?i)bitlocker' -or $name -match '(?i)disk\s*encryption') { + $assignments = Get-DetailedPolicyAssignments -EntityType 'deviceManagement/intents' -EntityId $intent.id -PolicyName $name -DebugMode:$DebugMode -ThrowOnError + $context.BitLockerPolicies.Add([PSCustomObject]@{ + Id = $intent.id + DisplayName = $name + Source = 'Intent' + Assignments = @($assignments) + }) } } - catch { Write-Verbose "intents fetch skipped: $($_.Exception.Message)" } # 5. Collect every groupId referenced by any of those policies' assignments, then # fetch transitive members for each group exactly once. @@ -720,8 +642,8 @@ function Get-BitLockerLapsAssignmentContext { if ($a.GroupId) { [void]$referencedGroupIds.Add([string]$a.GroupId) } } } - Write-Host " Resolving transitive members for $($referencedGroupIds.Count) group(s) used by BitLocker/LAPS assignments..." -ForegroundColor Cyan - # Initialise empty sets up front so every referenced group has entries even if a fetch fails. + Write-Verbose " Resolving transitive members for $($referencedGroupIds.Count) group(s) used by BitLocker/LAPS assignments..." + # Initialise empty sets up front so every referenced group has entries even if it was not found. foreach ($gid in $referencedGroupIds) { $context.GroupDeviceAadIds[$gid] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $context.GroupUserUpns[$gid] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) @@ -732,23 +654,23 @@ function Get-BitLockerLapsAssignmentContext { foreach ($gid in $referencedGroupIds) { $memberRequests.Add([PSCustomObject]@{ Id = "gd:$gid" - Url = "/groups/$gid/transitiveMembers/microsoft.graph.device?`$select=id,deviceId" + Url = "v1.0/groups/$gid/transitiveMembers/microsoft.graph.device?`$select=id,deviceId" }) $memberRequests.Add([PSCustomObject]@{ Id = "gu:$gid" - Url = "/groups/$gid/transitiveMembers/microsoft.graph.user?`$select=id,userPrincipalName" + Url = "v1.0/groups/$gid/transitiveMembers/microsoft.graph.user?`$select=id,userPrincipalName" }) } - # transitiveMembers lives on v1.0; route the batch there to keep sub-request URLs consistent. - $memberResponses = Invoke-RKGraphBatch -Requests @($memberRequests) -GraphVersion 'v1.0' -Activity 'Group transitive members' + # transitiveMembers lives on v1.0, so the request URLs name that version. + $memberResponses = Invoke-RKCoreGraphBatch -Requests @($memberRequests) # Any sub-response carrying @odata.nextLink overflows the first page (999 members). # Fall back to sequential paging for those groups only — rare in practice. $nextLinkFollowups = [System.Collections.Generic.List[object]]::new() foreach ($resp in $memberResponses) { - if ($resp.Status -ne 200 -or -not $resp.Body) { continue } + $body = Get-RKCoreBatchBody -Response $resp -What "the members of group $($resp.Id.Substring(3))" -Area 'BitLocker' -Warnings $Warnings; if (-not $body) { continue } $isUser = $resp.Id.StartsWith('gu:') $gid = $resp.Id.Substring(3) - foreach ($m in @($resp.Body.value)) { + foreach ($m in @($body.value)) { if ($isUser) { if ($m.id) { [void]$context.GroupUserIds[$gid].Add([string]$m.id) } if ($m.userPrincipalName) { [void]$context.GroupUserUpns[$gid].Add(([string]$m.userPrincipalName).ToLowerInvariant()) } @@ -756,95 +678,122 @@ function Get-BitLockerLapsAssignmentContext { if ($m.deviceId) { [void]$context.GroupDeviceAadIds[$gid].Add([string]$m.deviceId) } } } - if ($resp.Body.'@odata.nextLink') { - $nextLinkFollowups.Add([PSCustomObject]@{ Gid = $gid; IsUser = $isUser; NextUri = [string]$resp.Body.'@odata.nextLink' }) + if ($body.'@odata.nextLink') { + $nextLinkFollowups.Add([PSCustomObject]@{ Gid = $gid; IsUser = $isUser; NextUri = [string]$body.'@odata.nextLink' }) } } foreach ($f in $nextLinkFollowups) { - try { - $rest = Invoke-GraphRequestWithPaging -Uri $f.NextUri - foreach ($m in $rest) { - if ($f.IsUser) { - if ($m.id) { [void]$context.GroupUserIds[$f.Gid].Add([string]$m.id) } - if ($m.userPrincipalName) { [void]$context.GroupUserUpns[$f.Gid].Add(([string]$m.userPrincipalName).ToLowerInvariant()) } - } else { - if ($m.deviceId) { [void]$context.GroupDeviceAadIds[$f.Gid].Add([string]$m.deviceId) } - } + $rest = @(Invoke-RKCoreGraphRequest -Uri $f.NextUri -All) + foreach ($m in $rest) { + if ($f.IsUser) { + if ($m.id) { [void]$context.GroupUserIds[$f.Gid].Add([string]$m.id) } + if ($m.userPrincipalName) { [void]$context.GroupUserUpns[$f.Gid].Add(([string]$m.userPrincipalName).ToLowerInvariant()) } + } else { + if ($m.deviceId) { [void]$context.GroupDeviceAadIds[$f.Gid].Add([string]$m.deviceId) } } } - catch { Write-Verbose "Group $($f.Gid) nextLink follow-up failed: $($_.Exception.Message)" } } } # 6. BitLocker recovery keys. OS-volume key (volumeType == 1) is what protects the system drive. - # Use Invoke-MgGraphRequest directly (not the swallow-on-error paging helper) so a - # missing scope surfaces as a clear warning rather than an empty result set. - Write-Host ' Fetching BitLocker recovery key index (OS volume)...' -ForegroundColor Cyan + # A missing scope (BitlockerKey.ReadBasic.All, 401/403) sets BitLockerKeysReadable instead of + # reading as an empty key list; any other error throws. + Write-Verbose ' Fetching BitLocker recovery key index (OS volume)...' $bitLockerFetched = 0 try { - $bitlockerUri = 'https://graph.microsoft.com/v1.0/informationProtection/bitlocker/recoveryKeys?$select=id,deviceId,volumeType,createdDateTime' - do { - $resp = Invoke-MgGraphRequest -Uri $bitlockerUri -Method GET -OutputType PSObject -ErrorAction Stop - if ($resp.value) { - foreach ($k in $resp.value) { - $bitLockerFetched++ - if ([int]$k.volumeType -eq 1 -and $k.deviceId) { [void]$context.BitLockerKeyDeviceIds.Add([string]$k.deviceId) } - } - } - $bitlockerUri = $resp.'@odata.nextLink' - } while ($bitlockerUri) - Write-Host " Indexed $($context.BitLockerKeyDeviceIds.Count) device(s) with OS-volume BitLocker keys (from $bitLockerFetched key rotations)." -ForegroundColor Green + foreach ($k in @(Invoke-RKCoreGraphRequest -Uri 'v1.0/informationProtection/bitlocker/recoveryKeys?$select=id,deviceId,volumeType,createdDateTime' -All)) { + $bitLockerFetched++ + if ([int]$k.volumeType -eq 1 -and $k.deviceId) { [void]$context.BitLockerKeyDeviceIds.Add([string]$k.deviceId) } + } + Write-Verbose " Indexed $($context.BitLockerKeyDeviceIds.Count) device(s) with OS-volume BitLocker keys (from $bitLockerFetched key rotations)." } catch { - $msg = $_.Exception.Message - if ($msg -match '403|Forbidden|Authorization|scopes') { - Write-Warning "BitLocker recoveryKeys fetch DENIED. Most likely cause: the connected app is missing BitlockerKey.ReadBasic.All. Without it, every encrypted device will be flagged 'no key in Entra'. Reconnect with: Disconnect-RKGraph; Connect-RKGraph" + if ((Get-RKCoreGraphErrorStatus -ErrorRecord $_) -in 401, 403) { + $context.BitLockerKeysReadable = $false + $context.BitLockerKeysError = $_.Exception.Message } - else { Write-Warning "BitLocker recoveryKeys fetch failed: $msg" } + else { throw } } # 7. LAPS local credentials directory. Entry's `id` field IS the Azure AD device id # (verified against live Graph). Key the hashtable by it directly. - Write-Host ' Fetching directory/deviceLocalCredentials (Windows LAPS)...' -ForegroundColor Cyan + # A missing scope (DeviceLocalCredential.ReadBasic.All, 401/403) sets LapsCredentialsReadable. + Write-Verbose ' Fetching directory/deviceLocalCredentials (Windows LAPS)...' $lapsFetched = 0 try { - $lapsUri = 'https://graph.microsoft.com/v1.0/directory/deviceLocalCredentials?$select=id,deviceName,lastBackupDateTime,refreshDateTime' - do { - $resp = Invoke-MgGraphRequest -Uri $lapsUri -Method GET -OutputType PSObject -ErrorAction Stop - if ($resp.value) { - foreach ($e in $resp.value) { - $lapsFetched++ - if ($e.id) { $context.LapsCredentialByDeviceId[[string]$e.id] = $e } - if ($e.deviceName) { - $context.LapsCredentialByDeviceName[([string]$e.deviceName).ToLowerInvariant()] = $e - } - } + foreach ($e in @(Invoke-RKCoreGraphRequest -Uri 'v1.0/directory/deviceLocalCredentials?$select=id,deviceName,lastBackupDateTime,refreshDateTime' -All)) { + $lapsFetched++ + if ($e.id) { $context.LapsCredentialByDeviceId[[string]$e.id] = $e } + if ($e.deviceName) { + $context.LapsCredentialByDeviceName[([string]$e.deviceName).ToLowerInvariant()] = $e } - $lapsUri = $resp.'@odata.nextLink' - } while ($lapsUri) - Write-Host " Indexed $lapsFetched LAPS local credential record(s)." -ForegroundColor Green + } + Write-Verbose " Indexed $lapsFetched LAPS local credential record(s)." } catch { - $msg = $_.Exception.Message - if ($msg -match '403|Forbidden|Authorization|scopes') { - Write-Warning "deviceLocalCredentials fetch DENIED. Most likely cause: the connected app is missing DeviceLocalCredential.ReadBasic.All. Without it, every device will be flagged 'no LAPS backup'. Reconnect with: Disconnect-RKGraph; Connect-RKGraph" + if ((Get-RKCoreGraphErrorStatus -ErrorRecord $_) -in 401, 403) { + $context.LapsCredentialsReadable = $false + $context.LapsCredentialsError = $_.Exception.Message } - else { Write-Warning "deviceLocalCredentials fetch failed: $msg" } + else { throw } } # 8. Safety-net deviceName -> deviceId map. Only used when a managedDevice record # has no azureADDeviceId of its own (rare, but seen on partially-enrolled devices). - Write-Host ' Building Entra device name -> deviceId safety map...' -ForegroundColor Cyan + Write-Verbose ' Building Entra device name -> deviceId safety map...' try { - $entraDevices = Invoke-GraphRequestWithPaging -Uri 'https://graph.microsoft.com/v1.0/devices?$select=deviceId,displayName' + $entraDevices = @(Invoke-RKCoreGraphRequest -Uri 'v1.0/devices?$select=deviceId,displayName' -All) foreach ($ed in $entraDevices) { if ($ed.deviceId -and $ed.displayName) { $context.EntraDeviceIdByName[([string]$ed.displayName).ToLowerInvariant()] = [string]$ed.deviceId } } - Write-Host " Mapped $($context.EntraDeviceIdByName.Count) Entra device(s) by displayName." -ForegroundColor Green + Write-Verbose " Mapped $($context.EntraDeviceIdByName.Count) Entra device(s) by displayName." + } + catch { + foreach ($area in 'BitLocker', 'Laps') { $Warnings.Add([pscustomobject]@{ Area = $area; Message = "The Entra device list could not be read, so a device without an Entra device ID in Intune is not matched by name: $($_.Exception.Message)" }) } } - catch { Write-Warning "/v1.0/devices fetch failed: $($_.Exception.Message)" } return $context } + +function Get-RKIntuneAnomalyData { + <# + .SYNOPSIS + Collects everything the Intune anomaly resolvers need, once. Required reads throw; reads that need an + optional scope set a *Readable flag; best-effort reads add Warnings (Area, Message). + #> + [CmdletBinding()] + [OutputType([pscustomobject])] + param() + + Initialize-RKIntuneCoreState + $warnings = [System.Collections.Generic.List[object]]::new() + Write-Verbose 'Collecting Intune anomaly data' + + $organization = @(Invoke-RKCoreGraphRequest -Uri 'v1.0/organization?$select=id,displayName' -All)[0] + $tenantName = [string]$organization.displayName + $users = @(Invoke-RKCoreGraphRequest -Uri 'beta/users?$select=id,userPrincipalName,userType,accountEnabled' -All | Where-Object { $_.userType -eq 'Member' }) + $devices = @(Get-AllDeviceData -Users $users -TenantName $tenantName -Warnings $warnings) + $profiles = @(Get-AutopilotProfilesInformation) + $context = Get-BitLockerLapsAssignmentContext -Warnings $warnings + $apps = Get-IntuneAppData -TenantName $tenantName -Warnings $warnings + + [pscustomobject]@{ + TenantId = [string]$organization.id + TenantName = $tenantName + CollectedAt = Get-Date + Devices = $devices + Users = $users + AutopilotProfiles = $profiles + SecurityKeyContext = $context + Apps = $apps.Apps + AppsReadable = $apps.Readable + AppsError = $apps.Error + BitLockerKeysReadable = $context.BitLockerKeysReadable + BitLockerKeysError = $context.BitLockerKeysError + LapsCredentialsReadable = $context.LapsCredentialsReadable + LapsCredentialsError = $context.LapsCredentialsError + Warnings = $warnings.ToArray() + } +} From fc6a8051acc4b9f2700b953713a9167596fff119 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 25 Oct 2026 22:52:16 +0100 Subject: [PATCH 08/24] fix(core): null serials, LAPS group warnings, clearer compliance note Co-Authored-By: Claude Opus 5.5 --- Tests/IntuneAnomalyCore.Tests.ps1 | 35 +++++++++++++++++++++++++- module/Core/IntuneAnomalyCollector.ps1 | 8 +++--- module/Core/RKCoreGraph.ps1 | 6 ++--- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/Tests/IntuneAnomalyCore.Tests.ps1 b/Tests/IntuneAnomalyCore.Tests.ps1 index ec81a71..b11062a 100644 --- a/Tests/IntuneAnomalyCore.Tests.ps1 +++ b/Tests/IntuneAnomalyCore.Tests.ps1 @@ -59,6 +59,14 @@ Describe 'Get-RKCoreBatchBody' { $w[0].Message | Should -BeLike '*group g1*' } } + It 'Warns once per area for 404' { + InModuleScope RKSolutions { + $w = [System.Collections.Generic.List[object]]::new() + Get-RKCoreBatchBody -Response ([pscustomobject]@{ Id = 'a'; Status = 404; Body = $null }) -What 'group g1' -Area 'BitLocker', 'Laps' -Warnings $w | Should -BeNullOrEmpty + @($w.Area) | Should -Be @('BitLocker', 'Laps') + @($w | Where-Object { $_.Message -like '*group g1*' }).Count | Should -Be 2 + } + } It 'Throws for any other status' { { InModuleScope RKSolutions { $w = [System.Collections.Generic.List[object]]::new() @@ -240,8 +248,33 @@ Describe 'Collector error kinds' { } $w = [System.Collections.Generic.List[object]]::new() $ctx = Get-BitLockerLapsAssignmentContext -Warnings $w - @($w | Where-Object { $_.Area -eq 'BitLocker' -and $_.Message -like '*group g1*' }).Count | Should -Be 1 + @($w | Where-Object { $_.Message -like '*group g1*' }).Area | Should -Be @('BitLocker', 'Laps') $ctx.GroupUserUpns['g1'].Contains('user@contoso.com') | Should -BeTrue } } + It 'Get-IntuneAppData throws when the app list answers 500' { + InModuleScope RKSolutions { + Mock Invoke-RKCoreGraphBatch { } + Mock Invoke-RKCoreGraphRequest -ParameterFilter { $Uri -like 'beta/deviceAppManagement/mobileApps*' } { + $e = [System.Exception]::new('http 500') + $e | Add-Member -NotePropertyName Response -NotePropertyValue ([pscustomobject]@{ StatusCode = 500 }) + throw $e + } + $w = [System.Collections.Generic.List[object]]::new() + { Get-IntuneAppData -TenantName 'T' -Warnings $w } | Should -Throw 'http 500' + } + } + It 'Get-BitLockerLapsAssignmentContext throws when recoveryKeys answers 500' { + InModuleScope RKSolutions { + Mock Invoke-RKCoreGraphRequest { } + Mock Invoke-RKCoreGraphBatch { } + Mock Invoke-RKCoreGraphRequest -ParameterFilter { $Uri -like 'v1.0/informationProtection/bitlocker/recoveryKeys*' } { + $e = [System.Exception]::new('http 500') + $e | Add-Member -NotePropertyName Response -NotePropertyValue ([pscustomobject]@{ StatusCode = 500 }) + throw $e + } + $w = [System.Collections.Generic.List[object]]::new() + { Get-BitLockerLapsAssignmentContext -Warnings $w } | Should -Throw 'http 500' + } + } } diff --git a/module/Core/IntuneAnomalyCollector.ps1 b/module/Core/IntuneAnomalyCollector.ps1 index 03370e9..0b09eaa 100644 --- a/module/Core/IntuneAnomalyCollector.ps1 +++ b/module/Core/IntuneAnomalyCollector.ps1 @@ -274,7 +274,7 @@ function Get-AllDeviceData { # Best effort: a noncompliant device without rules shows as Unknown on the Noncompliant tab, so count them. $devicesWithoutRules = @($NonCompliantDevices | Where-Object { -not ($ComplianceRulesByDevice.ContainsKey($_.id) -and $ComplianceRulesByDevice[$_.id].Count -gt 0) }).Count - if ($devicesWithoutRules -gt 0) { $Warnings.Add([pscustomobject]@{ Area = 'Compliance'; Message = "The compliance reasons of $devicesWithoutRules device(s) could not be read; they show as Unknown." }) } + if ($devicesWithoutRules -gt 0) { $Warnings.Add([pscustomobject]@{ Area = 'Compliance'; Message = "The compliance reasons of $devicesWithoutRules device(s) are not available (they could not be read, or the device fails more than 10 policies); they show as Unknown." }) } } # Loop through all devices for device data @@ -292,8 +292,8 @@ function Get-AllDeviceData { $DeviceProperties = $DeviceData # Process Autopilot information via pre-built hashtable - $AutopilotInfo = $AutopilotLookup[$DeviceData.SerialNumber] - $HashUploaded = $AutopilotLookup.ContainsKey($DeviceData.SerialNumber) + $AutopilotInfo = $AutopilotLookup[[string]$DeviceData.SerialNumber] + $HashUploaded = $AutopilotLookup.ContainsKey([string]$DeviceData.SerialNumber) # Compliance rules were prefetched via $batch above; just look up. $FilteredForAlerting = @("DefaultDeviceCompliancePolicy.RequireDeviceCompliancePolicyAssigned", "DefaultDeviceCompliancePolicy.RequireRemainContact") @@ -667,7 +667,7 @@ function Get-BitLockerLapsAssignmentContext { # Fall back to sequential paging for those groups only — rare in practice. $nextLinkFollowups = [System.Collections.Generic.List[object]]::new() foreach ($resp in $memberResponses) { - $body = Get-RKCoreBatchBody -Response $resp -What "the members of group $($resp.Id.Substring(3))" -Area 'BitLocker' -Warnings $Warnings; if (-not $body) { continue } + $body = Get-RKCoreBatchBody -Response $resp -What "the members of group $($resp.Id.Substring(3))" -Area 'BitLocker', 'Laps' -Warnings $Warnings; if (-not $body) { continue } $isUser = $resp.Id.StartsWith('gu:') $gid = $resp.Id.Substring(3) foreach ($m in @($body.value)) { diff --git a/module/Core/RKCoreGraph.ps1 b/module/Core/RKCoreGraph.ps1 index 946de0f..6ad7efb 100644 --- a/module/Core/RKCoreGraph.ps1 +++ b/module/Core/RKCoreGraph.ps1 @@ -33,19 +33,19 @@ function Get-RKCoreBatchBody { <# .SYNOPSIS The body of a required batch response. 404 (an object deleted or referenced by a stale assignment) is - empty with a warning; any other status other than 200 throws. + empty with a warning per area; any other status other than 200 throws. #> [CmdletBinding()] param( [Parameter(Mandatory)] [pscustomobject] $Response, [Parameter(Mandatory)] [string] $What, - [Parameter(Mandatory)] [string] $Area, + [Parameter(Mandatory)] [string[]] $Area, [Parameter(Mandatory)] [AllowEmptyCollection()] [System.Collections.Generic.List[object]] $Warnings ) if ($Response.Status -eq 200) { return $Response.Body } if ($Response.Status -eq 404) { - $Warnings.Add([pscustomobject]@{ Area = $Area; Message = "$What was not found (404) and is skipped." }) + foreach ($a in $Area) { $Warnings.Add([pscustomobject]@{ Area = $a; Message = "$What was not found (404) and is skipped." }) } return $null } throw "Reading $What from Microsoft Graph failed with status $($Response.Status)." From f244cc4d51338ec40efe4d9e37021273e5dda0b7 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 25 Oct 2026 22:57:50 +0100 Subject: [PATCH 09/24] fix(core): fewer pages, wider stale-policy warning, collector tests Co-Authored-By: Claude Opus 5.5 --- Tests/IntuneAnomalyCore.Tests.ps1 | 107 +++++++++++++++++++++++++ module/Core/IntuneAnomalyCollector.ps1 | 19 ++--- 2 files changed, 117 insertions(+), 9 deletions(-) diff --git a/Tests/IntuneAnomalyCore.Tests.ps1 b/Tests/IntuneAnomalyCore.Tests.ps1 index b11062a..a4ce71b 100644 --- a/Tests/IntuneAnomalyCore.Tests.ps1 +++ b/Tests/IntuneAnomalyCore.Tests.ps1 @@ -278,3 +278,110 @@ Describe 'Collector error kinds' { } } } + +Describe 'Collector logic' { + It 'Get-AllDeviceData returns nothing, and throws nothing, for a tenant without devices' { + InModuleScope RKSolutions { + Mock Invoke-RKCoreGraphRequest { } + Mock Invoke-RKCoreGraphBatch { } + $w = [System.Collections.Generic.List[object]]::new() + @(Get-AllDeviceData -Users @() -TenantName '' -Warnings $w).Count | Should -Be 0 + $w.Count | Should -Be 0 + } + } + It 'Get-AllDeviceData asks once for the settingStates of a policy state listed twice' { + InModuleScope RKSolutions { + Mock Invoke-RKCoreGraphRequest { } + Mock Invoke-RKCoreGraphBatch { } + Mock Invoke-RKCoreGraphRequest -ParameterFilter { $Uri -like 'beta/deviceManagement/managedDevices?*' } { + [pscustomobject]@{ id = 'd1'; deviceName = 'PC1'; serialNumber = 'S1'; isEncrypted = $true; complianceState = 'noncompliant'; managementAgent = 'mdm' } + } + Mock Invoke-RKCoreGraphBatch -ParameterFilter { @($Requests.Url) -like '*/deviceCompliancePolicyStates' } { + $state = [pscustomobject]@{ id = 'ps1'; state = 'nonCompliant' } + [pscustomobject]@{ Id = 'ps:d1'; Status = 200; Body = [pscustomobject]@{ value = @($state, $state) } } + } + $w = [System.Collections.Generic.List[object]]::new() + $null = Get-AllDeviceData -Users @() -TenantName 'T' -Warnings $w + Should -Invoke Invoke-RKCoreGraphBatch -Times 1 -Exactly -ParameterFilter { + (@($Requests.Url) -join '|') -eq 'beta/deviceManagement/managedDevices/d1/deviceCompliancePolicyStates/ps1/settingStates' + } + } + } + It 'Get-AllDeviceData skips the settingStates of a device that fails more than 10 policies, and counts it' { + InModuleScope RKSolutions { + Mock Invoke-RKCoreGraphRequest { } + Mock Invoke-RKCoreGraphBatch { } + Mock Invoke-RKCoreGraphRequest -ParameterFilter { $Uri -like 'beta/deviceManagement/managedDevices?*' } { + [pscustomobject]@{ id = 'd2'; deviceName = 'PC2'; serialNumber = 'S2'; isEncrypted = $true; complianceState = 'noncompliant'; managementAgent = 'mdm' } + } + Mock Invoke-RKCoreGraphBatch -ParameterFilter { @($Requests.Url) -like '*/deviceCompliancePolicyStates' } { + [pscustomobject]@{ Id = 'ps:d2'; Status = 200; Body = [pscustomobject]@{ value = @(1..11 | ForEach-Object { [pscustomobject]@{ id = "s$_"; state = 'nonCompliant' } }) } } + } + $w = [System.Collections.Generic.List[object]]::new() + $null = Get-AllDeviceData -Users @() -TenantName 'T' -Warnings $w + Should -Invoke Invoke-RKCoreGraphBatch -Times 0 -Exactly -ParameterFilter { @($Requests.Url) -like '*/settingStates' } + @($w | Where-Object { $_.Area -eq 'Compliance' -and $_.Message -like 'The compliance reasons of 1 device(s)*' }).Count | Should -Be 1 + } + } + It 'Get-BitLockerLapsAssignmentContext keeps Entra-backed LAPS policies (_1), not on-premises ones (_2), and BitLocker policies' { + InModuleScope RKSolutions { + Mock Invoke-RKCoreGraphRequest { } + Mock Invoke-RKCoreGraphBatch { } + Mock Get-DetailedPolicyAssignments { } + Mock Invoke-RKCoreGraphRequest -ParameterFilter { $Uri -eq 'beta/deviceManagement/configurationPolicies' } { + [pscustomobject]@{ id = 'p1'; name = 'LAPS Entra' } + [pscustomobject]@{ id = 'p2'; name = 'LAPS AD' } + [pscustomobject]@{ id = 'p3'; name = 'BitLocker' } + } + Mock Invoke-RKCoreGraphBatch -ParameterFilter { @($Requests.Url) -like '*/settings?*' } { + foreach ($p in @{ p1 = 1; p2 = 2 }.GetEnumerator()) { + $choice = [pscustomobject]@{ value = "device_vendor_msft_laps_policies_backupdirectory_$($p.Value)" } + $setting = [pscustomobject]@{ settingInstance = [pscustomobject]@{ settingDefinitionId = 'device_vendor_msft_laps_policies_backupdirectory'; choiceSettingValue = $choice } } + [pscustomobject]@{ Id = "cps:$($p.Key)"; Status = 200; Body = [pscustomobject]@{ value = @($setting) } } + } + $bitLocker = [pscustomobject]@{ settingInstance = [pscustomobject]@{ settingDefinitionId = 'device_vendor_msft_bitlocker_requiredeviceencryption' } } + [pscustomobject]@{ Id = 'cps:p3'; Status = 200; Body = [pscustomobject]@{ value = @($bitLocker) } } + } + $w = [System.Collections.Generic.List[object]]::new() + $ctx = Get-BitLockerLapsAssignmentContext -Warnings $w + @($ctx.LapsPolicies.Id) | Should -Be @('p1') + @($ctx.BitLockerPolicies.Id) | Should -Be @('p3') + } + } + It 'Get-BitLockerLapsAssignmentContext follows the nextLink of a group-members page once and adds its members' { + InModuleScope RKSolutions { + Mock Invoke-RKCoreGraphRequest { } + Mock Invoke-RKCoreGraphBatch { } + Mock Invoke-RKCoreGraphRequest -ParameterFilter { $Uri -eq 'beta/deviceManagement/deviceConfigurations' } { + [pscustomobject]@{ id = 'c1'; displayName = 'BitLocker'; '@odata.type' = '#microsoft.graph.windows10EndpointProtectionConfiguration' } + } + Mock Get-DetailedPolicyAssignments { [pscustomobject]@{ GroupId = 'g1' } } + Mock Invoke-RKCoreGraphBatch -ParameterFilter { @($Requests.Url) -like '*/transitiveMembers/*' } { + $next = 'https://graph.microsoft.com/v1.0/groups/g1/transitiveMembers/microsoft.graph.device?$skiptoken=x' + [pscustomobject]@{ Id = 'gd:g1'; Status = 200; Body = [pscustomobject]@{ value = @([pscustomobject]@{ id = 'm1'; deviceId = 'dev1' }); '@odata.nextLink' = $next } } + [pscustomobject]@{ Id = 'gu:g1'; Status = 200; Body = [pscustomobject]@{ value = @() } } + } + Mock Invoke-RKCoreGraphRequest -ParameterFilter { $Uri -like '*skiptoken=x' } { [pscustomobject]@{ id = 'm2'; deviceId = 'dev2' } } + $w = [System.Collections.Generic.List[object]]::new() + $ctx = Get-BitLockerLapsAssignmentContext -Warnings $w + Should -Invoke Invoke-RKCoreGraphRequest -Times 1 -Exactly -ParameterFilter { $Uri -like '*skiptoken=x' -and $All } + @($ctx.GroupDeviceAadIds['g1'] | Sort-Object) | Should -Be @('dev1', 'dev2') + } + } + It 'Get-BitLockerLapsAssignmentContext marks the LAPS credentials unreadable on 403' { + InModuleScope RKSolutions { + Mock Invoke-RKCoreGraphRequest { } + Mock Invoke-RKCoreGraphBatch { } + Mock Invoke-RKCoreGraphRequest -ParameterFilter { $Uri -like 'v1.0/directory/deviceLocalCredentials*' } { + $e = [System.Exception]::new('http 403') + $e | Add-Member -NotePropertyName Response -NotePropertyValue ([pscustomobject]@{ StatusCode = 403 }) + throw $e + } + $w = [System.Collections.Generic.List[object]]::new() + $ctx = Get-BitLockerLapsAssignmentContext -Warnings $w + $ctx.LapsCredentialsReadable | Should -BeFalse + $ctx.LapsCredentialsError | Should -Be 'http 403' + $ctx.BitLockerKeysReadable | Should -BeTrue + } + } +} diff --git a/module/Core/IntuneAnomalyCollector.ps1 b/module/Core/IntuneAnomalyCollector.ps1 index 0b09eaa..2a66965 100644 --- a/module/Core/IntuneAnomalyCollector.ps1 +++ b/module/Core/IntuneAnomalyCollector.ps1 @@ -3,7 +3,7 @@ function Get-AllDeviceData { param( # Entra member users (id, userPrincipalName), for the logged-on users lookup. [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Users, - [Parameter(Mandatory)] [string] $TenantName, + [Parameter(Mandatory)] [AllowEmptyString()] [string] $TenantName, [Parameter(Mandatory)] [AllowEmptyCollection()] [System.Collections.Generic.List[object]] $Warnings ) @@ -117,7 +117,7 @@ function Get-AllDeviceData { $swStep = [System.Diagnostics.Stopwatch]::StartNew() $AllDeviceData = @(Invoke-RKCoreGraphRequest -Uri "beta/deviceManagement/managedDevices?`$filter=operatingSystem eq 'Windows'&`$select=$($Properties -join ',')" -All) #filter out managed by MDE - $AllDeviceData = $AllDeviceData | Where-Object { $_.managementAgent -ne "msSense" } + $AllDeviceData = @($AllDeviceData | Where-Object { $_.managementAgent -ne "msSense" }) $swStep.Stop() Write-Verbose ("[Get-AllDeviceData] Managed devices fetch: {0:N2}s ({1} devices)" -f $swStep.Elapsed.TotalSeconds, $AllDeviceData.Count) @@ -359,6 +359,7 @@ function Get-AllDeviceData { LastContact = $DeviceProperties.LastSyncDateTime DeviceHashUploaded = $HashUploaded DeviceAssociated = [bool]($DeviceData.SerialNumber -and $CorporateIdentifierSerials.Contains($DeviceData.SerialNumber)) + # Only looked up for devices that BitLocker or Autopilot would flag: $false means "not a Cloud PC, or not looked up". IsCloudPC = $CloudPcIds.Contains([string]$DeviceData.id) AutopilotGroupTag = $AutopilotInfo.groupTag AutopilotAssignedUser = if ($AutopilotInfo.userprincipalname) { $AutopilotInfo.userprincipalname } else { $null } @@ -512,10 +513,10 @@ function Get-BitLockerLapsAssignmentContext { $context.Filters = $script:AllFilters # 2. Discover Settings Catalog policies that contain BitLocker or LAPS settings. - # Two-step pattern: list policies (small payload, fits in 1-2 pages) then $batch-fetch - # /settings per policy. Avoids Graph hard-capping page size when $expand=settings is used - # (observed ~1-2 policies per page on cosmos-backed tenants); batch sub-requests execute - # server-side in parallel, so ~75 policies finish in ~4 batched round trips. + # Two-step pattern: list policies (small payload) then fetch /settings per policy. Avoids Graph + # hard-capping page size when $expand=settings is used on the list (observed ~1-2 policies per + # page on cosmos-backed tenants). The settings requests go through Invoke-RKCoreGraphBatch: the + # module batches them, the pack sends them one by one. Write-Verbose ' Discovering Settings Catalog policies (BitLocker / LAPS)...' $swCfg = [System.Diagnostics.Stopwatch]::StartNew() $configPolicies = @(Invoke-RKCoreGraphRequest -Uri 'beta/deviceManagement/configurationPolicies' -All) @@ -533,7 +534,7 @@ function Get-BitLockerLapsAssignmentContext { $settingsResponses = Invoke-RKCoreGraphBatch -Requests @($settingsRequests) $settingsByPolicyId = @{} foreach ($resp in $settingsResponses) { - $body = Get-RKCoreBatchBody -Response $resp -What "the settings of policy $($resp.Id -replace '^cps:', '')" -Area 'Settings' -Warnings $Warnings; if (-not $body) { continue } + $body = Get-RKCoreBatchBody -Response $resp -What "the settings of policy $($resp.Id -replace '^cps:', '')" -Area 'Settings', 'BitLocker', 'Laps' -Warnings $Warnings; if (-not $body) { continue } $policyId = $resp.Id -replace '^cps:', '' $settings = @($body.value) # $expand can make Graph return smaller pages: read the rest, so a policy with many settings is complete. @@ -742,7 +743,7 @@ function Get-BitLockerLapsAssignmentContext { # has no azureADDeviceId of its own (rare, but seen on partially-enrolled devices). Write-Verbose ' Building Entra device name -> deviceId safety map...' try { - $entraDevices = @(Invoke-RKCoreGraphRequest -Uri 'v1.0/devices?$select=deviceId,displayName' -All) + $entraDevices = @(Invoke-RKCoreGraphRequest -Uri 'v1.0/devices?$top=999&$select=deviceId,displayName' -All) foreach ($ed in $entraDevices) { if ($ed.deviceId -and $ed.displayName) { $context.EntraDeviceIdByName[([string]$ed.displayName).ToLowerInvariant()] = [string]$ed.deviceId @@ -773,7 +774,7 @@ function Get-RKIntuneAnomalyData { $organization = @(Invoke-RKCoreGraphRequest -Uri 'v1.0/organization?$select=id,displayName' -All)[0] $tenantName = [string]$organization.displayName - $users = @(Invoke-RKCoreGraphRequest -Uri 'beta/users?$select=id,userPrincipalName,userType,accountEnabled' -All | Where-Object { $_.userType -eq 'Member' }) + $users = @(Invoke-RKCoreGraphRequest -Uri 'beta/users?$top=999&$select=id,userPrincipalName,userType,accountEnabled' -All | Where-Object { $_.userType -eq 'Member' }) $devices = @(Get-AllDeviceData -Users $users -TenantName $tenantName -Warnings $warnings) $profiles = @(Get-AutopilotProfilesInformation) $context = Get-BitLockerLapsAssignmentContext -Warnings $warnings From 72ef31ce0f93b90d50e24539fbaa0d41da6e8258 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 25 Oct 2026 22:59:25 +0100 Subject: [PATCH 10/24] feat(core): resolvers take -Data and return Rows and Warnings; add the six device and app resolvers Co-Authored-By: Claude Sonnet 5.5 --- Tests/Consistency.Tests.ps1 | 6 +- Tests/IntuneAnomalyCore.Tests.ps1 | 68 ++++++++++++ module/Core/IntuneAnomalyResolvers.ps1 | 145 +++++++++++++++++++------ 3 files changed, 181 insertions(+), 38 deletions(-) diff --git a/Tests/Consistency.Tests.ps1 b/Tests/Consistency.Tests.ps1 index 2c7dfd1..1458c92 100644 --- a/Tests/Consistency.Tests.ps1 +++ b/Tests/Consistency.Tests.ps1 @@ -199,7 +199,8 @@ Describe 'BitLocker / LAPS anomaly resolver output contracts' { DeviceManufacturer = 'TestCorp' DeviceModel = 'TestModel' } - $rows = Resolve-IntuneBitLockerAnomalies -Devices @($d) -Context $ctx -TenantName 'TestTenant' + $data = [PSCustomObject]@{ TenantName = 'TestTenant'; Devices = @($d); SecurityKeyContext = $ctx; Warnings = @() } + $rows = (Resolve-IntuneBitLockerAnomalies -Data $data).Rows $rows[0] } $row | Should -Not -BeNullOrEmpty -Because 'an unencrypted device with no aadId must surface as an anomaly row' @@ -254,7 +255,8 @@ Describe 'BitLocker / LAPS anomaly resolver output contracts' { DeviceModel = 'TestModel' DeviceOwnership = 'company' # matches Get-AllDeviceData's output property } - $rows = Resolve-IntuneLapsAnomalies -Devices @($d) -Context $ctx -TenantName 'TestTenant' + $data = [PSCustomObject]@{ TenantName = 'TestTenant'; Devices = @($d); SecurityKeyContext = $ctx; Warnings = @() } + $rows = (Resolve-IntuneLapsAnomalies -Data $data).Rows $rows[0] } $row | Should -Not -BeNullOrEmpty -Because 'a device covered by a LAPS policy with no credential must surface' diff --git a/Tests/IntuneAnomalyCore.Tests.ps1 b/Tests/IntuneAnomalyCore.Tests.ps1 index a4ce71b..f065e41 100644 --- a/Tests/IntuneAnomalyCore.Tests.ps1 +++ b/Tests/IntuneAnomalyCore.Tests.ps1 @@ -385,3 +385,71 @@ Describe 'Collector logic' { } } } + +Describe 'Resolvers' { + BeforeAll { + $script:data = InModuleScope RKSolutions { + $old = (Get-Date).AddDays(-200) + [pscustomobject]@{ + TenantName = 'T' + Devices = @( + [pscustomobject]@{ Customer = 'T'; DeviceName = 'D1'; PrimaryUser = 'off@t'; Serialnumber = 'S1'; DeviceManufacturer = 'M'; DeviceModel = 'X'; LastContact = $old; DeviceHashUploaded = $false; DeviceAssociated = $false; IsCloudPC = $false; usersLoggedOnCount = 2; usersLoggedOnIds = 'a, b'; EnrollmentProfile = 'Single'; ComplianceStatus = 'noncompliant'; NoncompliantBasedOn = 'Require BitLocker, Real-time protection (turned off)'; NoncompliantAlert = 'Require BitLocker' } + [pscustomobject]@{ Customer = 'T'; DeviceName = 'D2'; PrimaryUser = 'on@t'; Serialnumber = 'S2'; DeviceManufacturer = 'M'; DeviceModel = 'X'; LastContact = (Get-Date); DeviceHashUploaded = $true; DeviceAssociated = $false; IsCloudPC = $false; usersLoggedOnCount = 1; usersLoggedOnIds = 'a'; EnrollmentProfile = 'Single'; ComplianceStatus = 'compliant'; NoncompliantBasedOn = ''; NoncompliantAlert = '' } + [pscustomobject]@{ Customer = 'T'; DeviceName = 'CPC'; PrimaryUser = 'on@t'; Serialnumber = 'S3'; DeviceManufacturer = 'M'; DeviceModel = 'X'; LastContact = (Get-Date); DeviceHashUploaded = $false; DeviceAssociated = $false; IsCloudPC = $true; usersLoggedOnCount = 1; usersLoggedOnIds = 'a'; EnrollmentProfile = ''; ComplianceStatus = 'compliant'; NoncompliantBasedOn = ''; NoncompliantAlert = '' } + ) + Users = @( + [pscustomobject]@{ id = '1'; userPrincipalName = 'off@t'; accountEnabled = $false } + [pscustomobject]@{ id = '2'; userPrincipalName = 'on@t'; accountEnabled = $true } + ) + AutopilotProfiles = @([pscustomobject]@{ displayName = 'Single'; outOfBoxExperienceSettings = [pscustomobject]@{ deviceUsageType = 'SingleUser' } }) + Apps = @( + [pscustomobject]@{ Application = 'A'; FailedDeviceCount = 0 } + [pscustomobject]@{ Application = 'B'; FailedDeviceCount = 3 } + [pscustomobject]@{ Application = 'C'; FailedDeviceCount = 9 } + ) + SecurityKeyContext = [pscustomobject]@{ ConfigurationPolicies = @() } + Warnings = @([pscustomobject]@{ Area = 'Compliance'; Message = 'w1' }, [pscustomobject]@{ Area = 'Apps'; Message = 'w2' }) + } + } + } + + It 'Inactive: devices older than -Days' { + $r = InModuleScope RKSolutions -Parameters @{ D = $script:data } { param($D) Resolve-IntuneInactiveDevices -Data $D -Days 90 } + @($r.Rows).DeviceName | Should -Be @('D1') + } + It 'Disabled primary user' { + $r = InModuleScope RKSolutions -Parameters @{ D = $script:data } { param($D) Resolve-IntuneDisabledPrimaryUsers -Data $D } + @($r.Rows).DeviceName | Should -Be @('D1') + } + It 'Missing Autopilot leaves out Cloud PCs' { + $r = InModuleScope RKSolutions -Parameters @{ D = $script:data } { param($D) Resolve-IntuneMissingAutopilotDevices -Data $D } + @($r.Rows).DeviceName | Should -Be @('D1') + } + It 'Multi-user only on single-user Autopilot profiles' { + $r = InModuleScope RKSolutions -Parameters @{ D = $script:data } { param($D) Resolve-IntuneMultiUserDevices -Data $D } + @($r.Rows).DeviceName | Should -Be @('D1') + } + It 'Noncompliant: one row per reason, with its Compliance warnings' { + $r = InModuleScope RKSolutions -Parameters @{ D = $script:data } { param($D) Resolve-IntuneNoncompliantDevices -Data $D } + @($r.Rows).Count | Should -Be 2 + @($r.Warnings).Message | Should -Be @('w1') + } + It 'App failures: only failing apps, highest first' { + $r = InModuleScope RKSolutions -Parameters @{ D = $script:data } { param($D) Resolve-IntuneAppInstallFailures -Data $D } + @($r.Rows).Application | Should -Be @('C', 'B') + @($r.Warnings).Message | Should -Be @('w2') + } + It 'Deprecated: reads display names from the expanded setting definitions' { + $r = InModuleScope RKSolutions { + $policy = [pscustomobject]@{ id = 'p1'; name = 'Pol'; platforms = 'windows10'; technologies = 'mdm'; settings = @( + [pscustomobject]@{ + settingInstance = [pscustomobject]@{ '@odata.type' = '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance'; settingDefinitionId = 'def_a'; choiceSettingValue = [pscustomobject]@{ value = 'def_a_1' } } + settingDefinitions = @([pscustomobject]@{ id = 'def_a'; displayName = 'Old thing (Deprecated)' }) + }) } + $d = [pscustomobject]@{ TenantName = 'T'; SecurityKeyContext = [pscustomobject]@{ ConfigurationPolicies = @($policy) }; Warnings = @() } + Resolve-IntuneDeprecatedAnomalies -Data $d + } + @($r.Rows).SettingDisplayName | Should -Be @('Old thing (Deprecated)') + @($r.Rows)[0].DetectionSource | Should -Be 'CatalogDisplayName' + } +} diff --git a/module/Core/IntuneAnomalyResolvers.ps1 b/module/Core/IntuneAnomalyResolvers.ps1 index 248118c..c9f0e0a 100644 --- a/module/Core/IntuneAnomalyResolvers.ps1 +++ b/module/Core/IntuneAnomalyResolvers.ps1 @@ -170,6 +170,80 @@ function Get-DeviceAppliedPolicies { return [PSCustomObject]@{ Applied = @($applied); Excluded = @($excluded) } } +function Select-RKAnomalyWarning { + <# + .SYNOPSIS + The collection warnings of the given areas. + #> + [CmdletBinding()] + param([Parameter(Mandatory)] $Data, [Parameter(Mandatory)] [string[]] $Area) + @($Data.Warnings | Where-Object { $_.Area -in $Area }) +} + +function Resolve-IntuneInactiveDevices { + <# .SYNOPSIS Windows devices whose last Intune sync is older than -Days (oldest first). #> + [CmdletBinding()] + param([Parameter(Mandatory)] $Data, [int] $Days = 90) + $cutoff = (Get-Date).AddDays(-$Days) + # Same test as the report always used: a device without LastContact also counts as inactive. + $rows = @($Data.Devices | Where-Object { $_.LastContact -lt $cutoff } | Sort-Object LastContact | + Select-Object Customer, DeviceName, PrimaryUser, Serialnumber, DeviceManufacturer, DeviceModel, LastContact) + [pscustomobject]@{ Rows = $rows; Warnings = @() } +} + +function Resolve-IntuneDisabledPrimaryUsers { + <# .SYNOPSIS Windows devices whose primary user is disabled in Entra ID. #> + [CmdletBinding()] + param([Parameter(Mandatory)] $Data) + $disabled = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($u in $Data.Users) { if ($u.accountEnabled -eq $false -and $u.userPrincipalName) { [void]$disabled.Add([string]$u.userPrincipalName) } } + $rows = @($Data.Devices | Where-Object { $_.PrimaryUser -and $disabled.Contains([string]$_.PrimaryUser) } | Sort-Object DeviceName | + Select-Object Customer, DeviceName, PrimaryUser, Serialnumber, DeviceManufacturer, DeviceModel) + [pscustomobject]@{ Rows = $rows; Warnings = @() } +} + +function Resolve-IntuneMissingAutopilotDevices { + <# .SYNOPSIS Physical Windows devices with neither an Autopilot hash nor a corporate identifier. #> + [CmdletBinding()] + param([Parameter(Mandatory)] $Data) + $rows = @($Data.Devices | Where-Object { -not $_.IsCloudPC -and $_.DeviceHashUploaded -eq $false -and -not $_.DeviceAssociated } | Sort-Object DeviceName | + Select-Object Customer, DeviceName, PrimaryUser, Serialnumber, DeviceManufacturer, DeviceModel) + [pscustomobject]@{ Rows = $rows; Warnings = Select-RKAnomalyWarning -Data $Data -Area 'Autopilot' } +} + +function Resolve-IntuneMultiUserDevices { + <# .SYNOPSIS Devices enrolled with a single-user Autopilot profile on which more than one user signed in. #> + [CmdletBinding()] + param([Parameter(Mandatory)] $Data) + $singleUser = @($Data.AutopilotProfiles | Where-Object { $_.outOfBoxExperienceSettings.deviceUsageType -eq 'SingleUser' } | ForEach-Object { $_.displayName }) + $rows = @($Data.Devices | Where-Object { $_.usersLoggedOnCount -gt 1 -and $_.EnrollmentProfile -in $singleUser } | Sort-Object DeviceName | + Select-Object Customer, DeviceName, PrimaryUser, EnrollmentProfile, usersLoggedOnCount, usersLoggedOnIds) + [pscustomobject]@{ Rows = $rows; Warnings = @() } +} + +function Resolve-IntuneNoncompliantDevices { + <# .SYNOPSIS Noncompliant Windows devices, one row per compliance rule that failed. #> + [CmdletBinding()] + param([Parameter(Mandatory)] $Data) + $rows = [System.Collections.Generic.List[object]]::new() + foreach ($device in @($Data.Devices | Where-Object { $_.ComplianceStatus -eq 'noncompliant' } | Sort-Object DeviceName)) { + $reasons = @(if ($device.NoncompliantBasedOn) { $device.NoncompliantBasedOn -split ', ' | ForEach-Object { $_.Trim() } | Where-Object { $_ } }) + if ($reasons.Count -eq 0) { $reasons = @('Unknown') } + foreach ($reason in $reasons) { + $rows.Add([pscustomobject]@{ Customer = $device.Customer; DeviceName = $device.DeviceName; PrimaryUser = $device.PrimaryUser; Serialnumber = $device.Serialnumber; DeviceManufacturer = $device.DeviceManufacturer; DeviceModel = $device.DeviceModel; ComplianceStatus = $device.ComplianceStatus; NoncompliantBasedOn = $reason; NoncompliantAlert = $device.NoncompliantAlert }) + } + } + [pscustomobject]@{ Rows = $rows.ToArray(); Warnings = Select-RKAnomalyWarning -Data $Data -Area 'Compliance' } +} + +function Resolve-IntuneAppInstallFailures { + <# .SYNOPSIS Windows apps that failed to install on at least one device (most failed devices first). #> + [CmdletBinding()] + param([Parameter(Mandatory)] $Data) + $rows = @($Data.Apps | Where-Object { $_.FailedDeviceCount -gt 0 } | Sort-Object FailedDeviceCount -Descending) + [pscustomobject]@{ Rows = $rows; Warnings = Select-RKAnomalyWarning -Data $Data -Area 'Apps' } +} + function Resolve-IntuneBitLockerAnomalies { <# For each Windows managed device, decide whether BitLocker is governed by an @@ -178,15 +252,14 @@ function Resolve-IntuneBitLockerAnomalies { A device that was deliberately excluded (via an exclude group or an assignment filter that rejected it) is *not* an anomaly by default - that's the admin's - explicit intent. Pass -ShowExcludedDevices to surface those rows as Info. + explicit intent. Those devices still come back, as Info rows. #> [CmdletBinding()] - param( - [Parameter(Mandatory)] [array] $Devices, - [Parameter(Mandatory)] [PSCustomObject] $Context, - [Parameter(Mandatory)] [string] $TenantName, - [switch] $ShowExcludedDevices - ) + param([Parameter(Mandatory)] $Data, [string] $TenantName) + if (-not $TenantName) { $TenantName = $Data.TenantName } + $Context = $Data.SecurityKeyContext + # Cloud PCs have platform-managed disk encryption. + $Devices = @($Data.Devices | Where-Object { -not $_.IsCloudPC }) $out = [System.Collections.Generic.List[PSObject]]::new() foreach ($d in $Devices) { $aadId = Resolve-DeviceAzureAdDeviceId -Device $d -Context $Context @@ -219,8 +292,7 @@ function Resolve-IntuneBitLockerAnomalies { $status = $null; $severity = $null if ($applied.Count -eq 0 -and $excluded.Count -gt 0) { - # Intentional exclusion. Skip unless caller asked to surface them. - if (-not $ShowExcludedDevices) { continue } + # Intentional exclusion, surfaced as Info. $reasons = ($excluded | Select-Object -ExpandProperty Reason -Unique) -join ', ' $status = "Intentionally excluded from BitLocker policies ($reasons)" $severity = 'Info' @@ -243,7 +315,7 @@ function Resolve-IntuneBitLockerAnomalies { Status = $status }) } - return $out + [pscustomobject]@{ Rows = $out.ToArray(); Warnings = Select-RKAnomalyWarning -Data $Data -Area 'BitLocker' } } function Resolve-IntuneLapsAnomalies { @@ -253,16 +325,14 @@ function Resolve-IntuneLapsAnomalies { A backup older than -MaxBackupAgeDays is reported as a rotation gap. Devices that were explicitly excluded (exclude group or assignment filter) are - suppressed by default; pass -ShowExcludedDevices to surface them as Info rows. + surfaced as Info rows. #> [CmdletBinding()] - param( - [Parameter(Mandatory)] [array] $Devices, - [Parameter(Mandatory)] [PSCustomObject] $Context, - [Parameter(Mandatory)] [string] $TenantName, - [int] $MaxBackupAgeDays = 60, - [switch] $ShowExcludedDevices - ) + param([Parameter(Mandatory)] $Data, [string] $TenantName, [int] $MaxBackupAgeDays = 60) + if (-not $TenantName) { $TenantName = $Data.TenantName } + $Context = $Data.SecurityKeyContext + # LAPS covers every device. + $Devices = @($Data.Devices) $out = [System.Collections.Generic.List[PSObject]]::new() $cutoff = (Get-Date).AddDays(-$MaxBackupAgeDays) @@ -291,7 +361,6 @@ function Resolve-IntuneLapsAnomalies { } if ($applied.Count -eq 0 -and $excluded.Count -gt 0) { - if (-not $ShowExcludedDevices) { continue } $reasons = ($excluded | Select-Object -ExpandProperty Reason -Unique) -join ', ' $status = "Intentionally excluded from LAPS policies ($reasons)" $severity = 'Info' @@ -314,7 +383,7 @@ function Resolve-IntuneLapsAnomalies { Status = $status }) } - return $out + [pscustomobject]@{ Rows = $out.ToArray(); Warnings = Select-RKAnomalyWarning -Data $Data -Area 'Laps' } } # ---------------------------------------------------------------------------- @@ -325,10 +394,10 @@ function Resolve-IntuneLapsAnomalies { # 1. Catalog DisplayName for the settingDefinitionId contains "deprecated" # 2. The setting's raw definition id contains "deprecated" # 3. The configured value string contains "deprecated" -# Catalog data comes from Get-RKIntuneSettingsCatalog (cached download from -# github.com/royklo/IntuneSettingsCatalogData); when the catalog is empty the -# walker still detects the subset that carries "deprecated" in the raw id or -# value string. +# Display names come from the $expand=settingDefinitions read of each policy's +# settings (done by the collector); when a setting has no definition the walker +# still detects the subset that carries "deprecated" in the raw id or value +# string. # ---------------------------------------------------------------------------- function Test-IsSettingDeprecated { @@ -434,19 +503,23 @@ function Get-IntuneSettingDeprecations { function Resolve-IntuneDeprecatedAnomalies { <# - Iterates the Settings Catalog policies already on the context, scans - every setting against the cached settings-catalog lookup, and emits one - row per deprecated setting in use. Loads / refreshes the catalog on - first call; subsequent calls in the same session reuse it. + Iterates the Settings Catalog policies already on the data object, scans + every setting against the expanded setting definitions, and emits one + row per deprecated setting in use. #> [CmdletBinding()] - param( - [Parameter(Mandatory)] [PSCustomObject] $Context, - [Parameter(Mandatory)] [string] $TenantName - ) + param([Parameter(Mandatory)] $Data, [string] $TenantName) + if (-not $TenantName) { $TenantName = $Data.TenantName } + $Context = $Data.SecurityKeyContext - Write-Host ' Scanning Settings Catalog policies for deprecated settings...' -ForegroundColor Cyan - $catalog = Get-RKIntuneSettingsCatalog + Write-Verbose 'Scanning Settings Catalog policies for deprecated settings...' + # Display names come from configurationPolicies/{id}/settings?$expand=settingDefinitions, read by the collector. + $catalog = @{} + foreach ($policy in $Context.ConfigurationPolicies) { + foreach ($s in @($policy.settings)) { + foreach ($def in @($s.settingDefinitions)) { if ($def.id -and -not $catalog.ContainsKey([string]$def.id)) { $catalog[[string]$def.id] = [pscustomobject]@{ DisplayName = [string]$def.displayName } } } + } + } $out = [System.Collections.Generic.List[PSObject]]::new() foreach ($policy in $Context.ConfigurationPolicies) { @@ -473,6 +546,6 @@ function Resolve-IntuneDeprecatedAnomalies { } } - Write-Host " Found $($out.Count) deprecated setting instance(s) across $($Context.ConfigurationPolicies.Count) Settings Catalog policies." -ForegroundColor Gray - return $out + Write-Verbose "Found $($out.Count) deprecated setting instance(s) across $($Context.ConfigurationPolicies.Count) Settings Catalog policies." + [pscustomobject]@{ Rows = @($out | Sort-Object PolicyName, SettingDisplayName); Warnings = Select-RKAnomalyWarning -Data $Data -Area 'Settings' } } From 1a3564509298e83301b585dcbfdb1366cca39d12 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 25 Oct 2026 23:04:08 +0100 Subject: [PATCH 11/24] test(core): pin resolver edge cases (exclusions as Info, UPN case, unknown reason, null LastContact, no Warnings) Co-Authored-By: Claude Sonnet 5.5 --- Tests/IntuneAnomalyCore.Tests.ps1 | 66 +++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/Tests/IntuneAnomalyCore.Tests.ps1 b/Tests/IntuneAnomalyCore.Tests.ps1 index f065e41..11091c8 100644 --- a/Tests/IntuneAnomalyCore.Tests.ps1 +++ b/Tests/IntuneAnomalyCore.Tests.ps1 @@ -439,6 +439,72 @@ Describe 'Resolvers' { @($r.Rows).Application | Should -Be @('C', 'B') @($r.Warnings).Message | Should -Be @('w2') } + It 'Disabled primary user matches the UPN case-insensitively' { + $r = InModuleScope RKSolutions { + $d = [pscustomobject]@{ + Devices = @([pscustomobject]@{ Customer = 'T'; DeviceName = 'U1'; PrimaryUser = 'OFF@T'; Serialnumber = 'S'; DeviceManufacturer = 'M'; DeviceModel = 'X' }) + Users = @([pscustomobject]@{ id = '1'; userPrincipalName = 'off@t'; accountEnabled = $false }) + } + Resolve-IntuneDisabledPrimaryUsers -Data $d + } + @($r.Rows).DeviceName | Should -Be @('U1') + } + It 'Noncompliant without a reason gives one row with Unknown' { + $r = InModuleScope RKSolutions { + $d = [pscustomobject]@{ Devices = @([pscustomobject]@{ Customer = 'T'; DeviceName = 'N1'; ComplianceStatus = 'noncompliant'; NoncompliantBasedOn = '' }); Warnings = @() } + Resolve-IntuneNoncompliantDevices -Data $d + } + @($r.Rows).Count | Should -Be 1 + @($r.Rows)[0].NoncompliantBasedOn | Should -Be 'Unknown' + } + It 'Inactive: a device without LastContact counts as inactive' { + $r = InModuleScope RKSolutions { + $d = [pscustomobject]@{ Devices = @([pscustomobject]@{ Customer = 'T'; DeviceName = 'Z1'; LastContact = $null }) } + Resolve-IntuneInactiveDevices -Data $d -Days 90 + } + @($r.Rows).DeviceName | Should -Be @('Z1') + } + It 'Resolvers work on a Data object without Warnings' { + $r = InModuleScope RKSolutions { + $d = [pscustomobject]@{ Devices = @(); Apps = @(); Users = @(); AutopilotProfiles = @() } + @( + (Resolve-IntuneNoncompliantDevices -Data $d) + (Resolve-IntuneAppInstallFailures -Data $d) + (Resolve-IntuneMissingAutopilotDevices -Data $d) + ) + } + foreach ($x in $r) { @($x.Warnings).Count | Should -Be 0 } + } + It 'Excluded devices come back as Info rows (BitLocker and LAPS)' { + $r = InModuleScope RKSolutions { + $policy = [pscustomobject]@{ DisplayName = 'P'; Source = 'Catalog'; Assignments = @( + [pscustomobject]@{ AssignmentType = 'All Devices' } + [pscustomobject]@{ AssignmentType = 'Group (Exclude)'; GroupId = 'g1' }) } + $ctx = [pscustomobject]@{ + EntraDeviceIdByName = @{} + GroupDeviceAadIds = @{ g1 = [System.Collections.Generic.HashSet[string]]::new([string[]]@('aad1')) } + GroupUserUpns = @{} + Filters = @{} + BitLockerPolicies = @($policy) + BitLockerKeyDeviceIds = [System.Collections.Generic.HashSet[string]]::new() + LapsPolicies = @($policy) + LapsCredentialByDeviceId = @{} + LapsCredentialByDeviceName = @{} + } + $d = [pscustomobject]@{ + TenantName = 'T' + Devices = @([pscustomobject]@{ DeviceName = 'E1'; AzureAdDeviceId = 'aad1'; PrimaryUser = 'u@t'; Serialnumber = 'S'; Encrypted = $false; IsCloudPC = $false }) + SecurityKeyContext = $ctx + Warnings = @() + } + @{ BitLocker = (Resolve-IntuneBitLockerAnomalies -Data $d); Laps = (Resolve-IntuneLapsAnomalies -Data $d) } + } + foreach ($k in 'BitLocker', 'Laps') { + @($r[$k].Rows).Count | Should -Be 1 + @($r[$k].Rows)[0].Severity | Should -Be 'Info' + @($r[$k].Rows)[0].Status | Should -BeLike 'Intentionally excluded*' + } + } It 'Deprecated: reads display names from the expanded setting definitions' { $r = InModuleScope RKSolutions { $policy = [pscustomobject]@{ id = 'p1'; name = 'Pol'; platforms = 'windows10'; technologies = 'mdm'; settings = @( From beea41bfb9ae32dfbf722073f428e4bf2bf350f6 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 25 Oct 2026 23:05:48 +0100 Subject: [PATCH 12/24] feat: Get-RKIntuneAnomaly, and Get-IntuneAnomaliesReport renders its result with permission notices Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 8 +++ docs/CMDLET-REFERENCE.md | 19 ++++++ module/Private/IntuneAnomalies.ps1 | 9 +++ module/Public/Get-IntuneAnomaliesReport.ps1 | 69 ++++----------------- module/Public/Get-RKIntuneAnomaly.ps1 | 58 +++++++++++++++++ module/RKSolutions.psd1 | 1 + module/RKSolutions.psm1 | 1 + 7 files changed, 107 insertions(+), 58 deletions(-) create mode 100644 module/Public/Get-RKIntuneAnomaly.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 069243e..28f29b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format follows [Conventional Commits](https://www.conventionalcommits.org/) +## Unreleased + +- Added `Get-RKIntuneAnomaly`. +- App install failures now read per-app install summaries (GET) and the full app list. +- Deprecated settings now read setting display names from Microsoft Graph instead of a download. +- A missing BitLocker, LAPS or app permission is now shown as a notice instead of flagging every device. +- Graph errors in required reads now stop the report instead of being silently ignored. + ## [1.2.2] - 2026-09-24 Patch release for the Intune Anomalies report. diff --git a/docs/CMDLET-REFERENCE.md b/docs/CMDLET-REFERENCE.md index bfda429..d4fcb28 100644 --- a/docs/CMDLET-REFERENCE.md +++ b/docs/CMDLET-REFERENCE.md @@ -76,6 +76,25 @@ Connect first with **Connect-RKGraph**; this cmdlet uses the existing connection --- +## Get-RKIntuneAnomaly + +Returns the Intune anomalies of the connected tenant as objects, one property per category. + +Collects the Windows devices, apps, BitLocker and LAPS state and Settings Catalog policies once, and returns: BitLocker, Laps, Noncompliant, Inactive, DisabledPrimaryUser, MissingAutopilot, MultiUser, AppInstallFailures, DeprecatedSettings and OperatingSystemEditions (rows), plus Warnings and the BitLockerKeysReadable, LapsCredentialsReadable and AppsReadable flags. Get-IntuneAnomaliesReport renders the same object as HTML. Connect first with **Connect-RKGraph**. + + +| Parameter | Description | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------- | +| **InactiveDays** | A device whose last Intune sync is older than this many days is inactive. Default 90. | +| **MaxLapsBackupAgeDays** | A LAPS backup older than this many days is stale. Default 60. | +| **ShowExcludedDevices** | Also return devices that a BitLocker or LAPS policy leaves out on purpose (exclude group or filter), as Info rows. | + + +**Examples:** +- `Get-RKIntuneAnomaly | Select-Object -ExpandProperty BitLocker | Export-Csv bitlocker.csv` + +--- + ## Get-EntraAdminRolesReport Generates Entra admin roles report. diff --git a/module/Private/IntuneAnomalies.ps1 b/module/Private/IntuneAnomalies.ps1 index 0317eb7..af49608 100644 --- a/module/Private/IntuneAnomalies.ps1 +++ b/module/Private/IntuneAnomalies.ps1 @@ -26,6 +26,8 @@ function New-IntuneAnomaliesHTMLReport { [Parameter(Mandatory = $false)] [array]$Report_DeprecatedSettings, [Parameter(Mandatory = $false)] + [string[]]$Notices, + [Parameter(Mandatory = $false)] [string]$ExportPath ) @@ -271,8 +273,15 @@ function New-IntuneAnomaliesHTMLReport { "@ + $noticesHtml = '' + if ($Notices) { + $noticeItems = ($Notices | ForEach-Object { '
  • ' + [System.Net.WebUtility]::HtmlEncode($_) + '
  • ' }) -join '' + $noticesHtml = "
    Notes from this run:
      $noticeItems
    " + } + # Build body content HTML (tabs + panels + filter containers + tables + script) $bodyContentHtml = @" + $noticesHtml
    diff --git a/module/Public/Get-IntuneAnomaliesReport.ps1 b/module/Public/Get-IntuneAnomaliesReport.ps1 index e1ead83..89d37d5 100644 --- a/module/Public/Get-IntuneAnomaliesReport.ps1 +++ b/module/Public/Get-IntuneAnomaliesReport.ps1 @@ -26,68 +26,21 @@ try { $ctx = Get-MgContext -ErrorAction SilentlyContinue if (-not $ctx) { throw 'Not connected to Microsoft Graph. Run Connect-RKGraph first.' } - $tenantInfo = Invoke-MgGraphRequest -Uri 'beta/organization' -Method Get -OutputType PSObject - $tenantname = $tenantInfo.value[0].displayName - - $swPhase.Restart() - $AllEntraIDUsers = Invoke-GraphRequestWithPaging -Uri "https://graph.microsoft.com/beta/users/?`$select=id,userPrincipalName,userType,accountEnabled" | Where-Object { $_.UserType -eq 'Member' } - $DisabledEntraUsers = $AllEntraIDUsers | Where-Object { $_.accountEnabled -eq $false } | Select-Object id, userPrincipalName, userType, accountEnabled - $swPhase.Stop(); Write-Verbose ("[Report] Entra users fetch: {0:N2}s ({1} members)" -f $swPhase.Elapsed.TotalSeconds, $AllEntraIDUsers.Count) - - Write-Host 'Starting device data collection...' -ForegroundColor Yellow - $swPhase.Restart() - $DeviceData = Get-AllDeviceData - $swPhase.Stop(); Write-Verbose ("[Report] Get-AllDeviceData: {0:N2}s" -f $swPhase.Elapsed.TotalSeconds) - - $swPhase.Restart() - $AutopilotProfilesInformation = Get-AutopilotProfilesInformation - $UserDrivenAutopilotProfiles = $AutopilotProfilesInformation | Where-Object { $_.outOfBoxExperienceSettings.deviceUsageType -eq 'SingleUser' } - $swPhase.Stop(); Write-Verbose ("[Report] Autopilot profiles: {0:N2}s" -f $swPhase.Elapsed.TotalSeconds) - - Write-Host 'Resolving BitLocker / Windows LAPS policy assignments and key escrow state...' -ForegroundColor Yellow + Write-Host 'Collecting Intune anomaly data...' -ForegroundColor Yellow $swPhase.Restart() - $SecurityKeyContext = Get-BitLockerLapsAssignmentContext -DebugMode:$DebugMode - $swPhase.Stop(); Write-Verbose ("[Report] BitLocker/LAPS context: {0:N2}s" -f $swPhase.Elapsed.TotalSeconds) + $anomaly = Get-RKIntuneAnomaly -ShowExcludedDevices:$ShowExcludedDevices + $swPhase.Stop(); Write-Verbose ("[Report] Get-RKIntuneAnomaly: {0:N2}s" -f $swPhase.Elapsed.TotalSeconds) + $tenantname = $anomaly.TenantName - # Cloud PCs (Windows 365 / Dev Box) have platform-managed disk encryption and are never Autopilot-registered. - $PhysicalDevices = @($DeviceData | Where-Object { -not $_.IsCloudPC }) - - $swPhase.Restart() - $Report_BitLockerStatus = Resolve-IntuneBitLockerAnomalies -Devices $PhysicalDevices -Context $SecurityKeyContext -TenantName $tenantname -ShowExcludedDevices:$ShowExcludedDevices - $Report_LapsStatus = Resolve-IntuneLapsAnomalies -Devices $DeviceData -Context $SecurityKeyContext -TenantName $tenantname -ShowExcludedDevices:$ShowExcludedDevices - $Report_DeprecatedSettings = Resolve-IntuneDeprecatedAnomalies -Context $SecurityKeyContext -TenantName $tenantname - $swPhase.Stop(); Write-Verbose ("[Report] BitLocker + LAPS + Deprecated resolution: {0:N2}s" -f $swPhase.Elapsed.TotalSeconds) - - $swPhase.Restart() - $Report_ApplicationFailureReport = Get-ApplicationFailures - $swPhase.Stop(); Write-Verbose ("[Report] Get-ApplicationFailures: {0:N2}s" -f $swPhase.Elapsed.TotalSeconds) - $Report_DevicesWithMultipleUsers = $DeviceData | Where-Object { $_.usersLoggedOnCount -gt 1 -and $_.EnrollmentProfile -in $UserDrivenAutopilotProfiles.displayName } | Select-Object Customer, DeviceName, PrimaryUser, EnrollmentProfile, usersLoggedOnCount, usersLoggedOnIds - $Report_OperatingSystemEditionOverview = $DeviceData | Select-Object Customer, DeviceName, PrimaryUser, OperatingSystemEdition, OSFriendlyname - # Not Encrypted is now folded into the BitLocker tab - unencrypted devices - # show up there as 'Device not encrypted and no BitLocker policy assigned' or - # 'BitLocker policy assigned but device not encrypted' so we don't need a - # standalone tab anymore. - $Report_DevicesWithoutAutopilotHash = $PhysicalDevices | Where-Object { $_.DeviceHashUploaded -eq $false -and -not $_.DeviceAssociated } | Select-Object Customer, DeviceName, PrimaryUser, Serialnumber, DeviceManufacturer, DeviceModel - $Report_InactiveDevices = $DeviceData | Where-Object { $_.LastContact -lt (Get-Date).AddDays(-90) } | Select-Object Customer, DeviceName, PrimaryUser, Serialnumber, DeviceManufacturer, DeviceModel, LastContact - $Report_DisabledPrimaryUsers = $DeviceData | Where-Object { $_.PrimaryUser -in $DisabledEntraUsers.userPrincipalName } | Select-Object Customer, DeviceName, PrimaryUser, Serialnumber, DeviceManufacturer, DeviceModel - - $Report_NoncompliantDevices = [System.Collections.Generic.List[PSObject]]::new() - $NoncompliantDevicesRaw = $DeviceData | Where-Object { $_.ComplianceStatus -eq 'noncompliant' } - foreach ($device in $NoncompliantDevicesRaw) { - if ($device.NoncompliantBasedOn) { - $reasons = $device.NoncompliantBasedOn -split ', ' - foreach ($reason in $reasons) { - if ($reason.Trim()) { - $Report_NoncompliantDevices.Add([PSCustomObject]@{ Customer = $device.Customer; DeviceName = $device.DeviceName; PrimaryUser = $device.PrimaryUser; Serialnumber = $device.Serialnumber; DeviceManufacturer = $device.DeviceManufacturer; DeviceModel = $device.DeviceModel; ComplianceStatus = $device.ComplianceStatus; NoncompliantBasedOn = $reason.Trim(); NoncompliantAlert = $device.NoncompliantAlert }) - } - } - } else { - $Report_NoncompliantDevices.Add([PSCustomObject]@{ Customer = $device.Customer; DeviceName = $device.DeviceName; PrimaryUser = $device.PrimaryUser; Serialnumber = $device.Serialnumber; DeviceManufacturer = $device.DeviceManufacturer; DeviceModel = $device.DeviceModel; ComplianceStatus = $device.ComplianceStatus; NoncompliantBasedOn = 'Unknown'; NoncompliantAlert = $device.NoncompliantAlert }) - } - } + $notices = [System.Collections.Generic.List[string]]::new() + if (-not $anomaly.BitLockerKeysReadable) { $notices.Add('BitLocker recovery keys could not be read (BitlockerKey.ReadBasic.All is missing). The BitLocker tab cannot tell which keys are in Entra ID.') } + if (-not $anomaly.LapsCredentialsReadable) { $notices.Add('Windows LAPS credentials could not be read (DeviceLocalCredential.ReadBasic.All is missing). The LAPS tab cannot tell which devices backed up a password.') } + if (-not $anomaly.AppsReadable) { $notices.Add('Apps could not be read (DeviceManagementApps.Read.All is missing). The Application Failures tab is empty.') } + foreach ($w in $anomaly.Warnings) { $notices.Add("$($w.Area): $($w.Message)") } + foreach ($n in $notices) { Write-Warning $n } $swPhase.Restart() - New-IntuneAnomaliesHTMLReport -TenantName $tenantname -Report_ApplicationFailureReport $Report_ApplicationFailureReport -Report_DevicesWithMultipleUsers $Report_DevicesWithMultipleUsers -Report_DevicesWithoutAutopilotHash $Report_DevicesWithoutAutopilotHash -Report_InactiveDevices $Report_InactiveDevices -Report_NoncompliantDevices $Report_NoncompliantDevices -Report_OperatingSystemEditionOverview $Report_OperatingSystemEditionOverview -Report_DisabledPrimaryUsers $Report_DisabledPrimaryUsers -Report_BitLockerStatus $Report_BitLockerStatus -Report_LapsStatus $Report_LapsStatus -Report_DeprecatedSettings $Report_DeprecatedSettings -ExportPath $ExportPath + New-IntuneAnomaliesHTMLReport -TenantName $tenantname -Report_ApplicationFailureReport $anomaly.AppInstallFailures -Report_DevicesWithMultipleUsers $anomaly.MultiUser -Report_DevicesWithoutAutopilotHash $anomaly.MissingAutopilot -Report_InactiveDevices $anomaly.Inactive -Report_NoncompliantDevices $anomaly.Noncompliant -Report_OperatingSystemEditionOverview $anomaly.OperatingSystemEditions -Report_DisabledPrimaryUsers $anomaly.DisabledPrimaryUser -Report_BitLockerStatus $anomaly.BitLocker -Report_LapsStatus $anomaly.Laps -Report_DeprecatedSettings $anomaly.DeprecatedSettings -Notices $notices.ToArray() -ExportPath $ExportPath $swPhase.Stop(); Write-Verbose ("[Report] HTML build: {0:N2}s" -f $swPhase.Elapsed.TotalSeconds) $swReport.Stop(); Write-Verbose ("[Report] TOTAL Get-IntuneAnomaliesReport: {0:N2}s" -f $swReport.Elapsed.TotalSeconds) diff --git a/module/Public/Get-RKIntuneAnomaly.ps1 b/module/Public/Get-RKIntuneAnomaly.ps1 new file mode 100644 index 0000000..2f960f0 --- /dev/null +++ b/module/Public/Get-RKIntuneAnomaly.ps1 @@ -0,0 +1,58 @@ +<# +.SYNOPSIS + Returns the Intune anomalies of the connected tenant as objects, one property per category. + +.DESCRIPTION + Collects the Windows devices, apps, BitLocker and LAPS state and Settings Catalog policies once, and returns: + BitLocker, Laps, Noncompliant, Inactive, DisabledPrimaryUser, MissingAutopilot, MultiUser, AppInstallFailures, + DeprecatedSettings and OperatingSystemEditions (rows), plus Warnings and the BitLockerKeysReadable, + LapsCredentialsReadable and AppsReadable flags. Get-IntuneAnomaliesReport renders the same object as HTML. + Connect first with Connect-RKGraph. + +.PARAMETER InactiveDays + A device whose last Intune sync is older than this many days is inactive. Default 90. + +.PARAMETER MaxLapsBackupAgeDays + A LAPS backup older than this many days is stale. Default 60. + +.PARAMETER ShowExcludedDevices + Also return devices that a BitLocker or LAPS policy leaves out on purpose (exclude group or filter), as Info rows. + +.EXAMPLE + Get-RKIntuneAnomaly | Select-Object -ExpandProperty BitLocker | Export-Csv bitlocker.csv +#> +function Get-RKIntuneAnomaly { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [ValidateRange(1, 3650)] [int] $InactiveDays = 90, + [ValidateRange(1, 3650)] [int] $MaxLapsBackupAgeDays = 60, + [switch] $ShowExcludedDevices + ) + + if (-not (Get-MgContext -ErrorAction SilentlyContinue)) { throw 'Not connected to Microsoft Graph. Run Connect-RKGraph first.' } + + $data = Get-RKIntuneAnomalyData + $results = [ordered]@{ + BitLocker = Resolve-IntuneBitLockerAnomalies -Data $data + Laps = Resolve-IntuneLapsAnomalies -Data $data -MaxBackupAgeDays $MaxLapsBackupAgeDays + Noncompliant = Resolve-IntuneNoncompliantDevices -Data $data + Inactive = Resolve-IntuneInactiveDevices -Data $data -Days $InactiveDays + DisabledPrimaryUser = Resolve-IntuneDisabledPrimaryUsers -Data $data + MissingAutopilot = Resolve-IntuneMissingAutopilotDevices -Data $data + MultiUser = Resolve-IntuneMultiUserDevices -Data $data + AppInstallFailures = Resolve-IntuneAppInstallFailures -Data $data + DeprecatedSettings = Resolve-IntuneDeprecatedAnomalies -Data $data + } + $out = [ordered]@{ TenantName = $data.TenantName; CollectedAt = $data.CollectedAt } + foreach ($name in $results.Keys) { $out[$name] = @($results[$name].Rows) } + # @(...) keeps an empty result an empty array; without it, zero rows would become $null. + $out.BitLocker = @($out.BitLocker | Where-Object { $ShowExcludedDevices -or $_.Severity -ne 'Info' }) + $out.Laps = @($out.Laps | Where-Object { $ShowExcludedDevices -or $_.Severity -ne 'Info' }) + $out.OperatingSystemEditions = @($data.Devices | Select-Object Customer, DeviceName, PrimaryUser, OperatingSystemEdition, OSFriendlyname) + $out.Warnings = @($results.Values | ForEach-Object { $_.Warnings } | Sort-Object Area, Message -Unique) + $out.BitLockerKeysReadable = $data.BitLockerKeysReadable + $out.LapsCredentialsReadable = $data.LapsCredentialsReadable + $out.AppsReadable = $data.AppsReadable + [pscustomobject]$out +} diff --git a/module/RKSolutions.psd1 b/module/RKSolutions.psd1 index 7036899..e8780fe 100644 --- a/module/RKSolutions.psd1 +++ b/module/RKSolutions.psd1 @@ -13,6 +13,7 @@ 'Disconnect-RKGraph', 'Get-IntuneEnrollmentFlowsReport', 'Get-IntuneAnomaliesReport', + 'Get-RKIntuneAnomaly', 'Get-EntraAdminRolesReport', 'Get-M365LicenseAssignmentReport', 'Get-CustomSecurityAttributesReport' diff --git a/module/RKSolutions.psm1 b/module/RKSolutions.psm1 index e3bfdc0..bc29793 100644 --- a/module/RKSolutions.psm1 +++ b/module/RKSolutions.psm1 @@ -69,6 +69,7 @@ Export-ModuleMember -Function @( 'Disconnect-RKGraph', 'Get-IntuneEnrollmentFlowsReport', 'Get-IntuneAnomaliesReport', + 'Get-RKIntuneAnomaly', 'Get-EntraAdminRolesReport', 'Get-M365LicenseAssignmentReport', 'Get-CustomSecurityAttributesReport' From c64a29069efc14cdee6ed6c7454c5aa2dfaf949e Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 25 Oct 2026 23:10:12 +0100 Subject: [PATCH 13/24] fix: document that -DebugMode on Get-IntuneAnomaliesReport has no effect, and list Get-RKIntuneAnomaly Co-Authored-By: Claude Sonnet 5.5 --- README.md | 1 + docs/CMDLET-REFERENCE.md | 6 ++++-- module/Public/Get-IntuneAnomaliesReport.ps1 | 2 ++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d4a0ee8..ce97070 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ Disconnect-RKGraph | **Disconnect-RKGraph** | Disconnects and clears the Graph session. | | **Get-IntuneEnrollmentFlowsReport** | Generates Intune assignment overview and/or device visualization report. | | **Get-IntuneAnomaliesReport** | Generates Intune anomalies report. | +| **Get-RKIntuneAnomaly** | Returns the Intune anomalies of the connected tenant as objects. | | **Get-EntraAdminRolesReport** | Generates Entra admin roles report. | | **Get-M365LicenseAssignmentReport** | Generates M365 license assignment report. | | **Get-CustomSecurityAttributesReport** | Generates Custom Security Attributes report across users, devices, and apps. | diff --git a/docs/CMDLET-REFERENCE.md b/docs/CMDLET-REFERENCE.md index d4fcb28..49c6d99 100644 --- a/docs/CMDLET-REFERENCE.md +++ b/docs/CMDLET-REFERENCE.md @@ -64,7 +64,7 @@ Generates an interactive HTML report covering Intune app failures, multi-user de | **From** | From address. | | **ExportPath** | Output file path. | | **ShowExcludedDevices** | Also surface devices that were *deliberately* excluded from BitLocker / LAPS policies (via exclude group or assignment filter) as Info-severity rows. Off by default so the report stays focused on actual anomalies. | -| **DebugMode** | Enable debug output. | +| **DebugMode** | No effect; kept for compatibility. Use -Verbose. | Connect first with **Connect-RKGraph**; this cmdlet uses the existing connection (no auth parameters). @@ -80,7 +80,7 @@ Connect first with **Connect-RKGraph**; this cmdlet uses the existing connection Returns the Intune anomalies of the connected tenant as objects, one property per category. -Collects the Windows devices, apps, BitLocker and LAPS state and Settings Catalog policies once, and returns: BitLocker, Laps, Noncompliant, Inactive, DisabledPrimaryUser, MissingAutopilot, MultiUser, AppInstallFailures, DeprecatedSettings and OperatingSystemEditions (rows), plus Warnings and the BitLockerKeysReadable, LapsCredentialsReadable and AppsReadable flags. Get-IntuneAnomaliesReport renders the same object as HTML. Connect first with **Connect-RKGraph**. +Collects the Windows devices, apps, BitLocker and LAPS state and Settings Catalog policies once, and returns: BitLocker, Laps, Noncompliant, Inactive, DisabledPrimaryUser, MissingAutopilot, MultiUser, AppInstallFailures, DeprecatedSettings and OperatingSystemEditions (rows), plus Warnings and the BitLockerKeysReadable, LapsCredentialsReadable and AppsReadable flags. Get-IntuneAnomaliesReport renders the same object as HTML. | Parameter | Description | @@ -89,6 +89,8 @@ Collects the Windows devices, apps, BitLocker and LAPS state and Settings Catalo | **MaxLapsBackupAgeDays** | A LAPS backup older than this many days is stale. Default 60. | | **ShowExcludedDevices** | Also return devices that a BitLocker or LAPS policy leaves out on purpose (exclude group or filter), as Info rows. | +Connect first with **Connect-RKGraph**; this cmdlet uses the existing connection (no auth parameters). + **Examples:** - `Get-RKIntuneAnomaly | Select-Object -ExpandProperty BitLocker | Export-Csv bitlocker.csv` diff --git a/module/Public/Get-IntuneAnomaliesReport.ps1 b/module/Public/Get-IntuneAnomaliesReport.ps1 index 89d37d5..ac335fc 100644 --- a/module/Public/Get-IntuneAnomaliesReport.ps1 +++ b/module/Public/Get-IntuneAnomaliesReport.ps1 @@ -4,6 +4,7 @@ Connect first with Connect-RKGraph; this cmdlet uses the existing connection. #> function Get-IntuneAnomaliesReport { +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'DebugMode', Justification = 'Kept for compatibility')] [CmdletBinding()] param( [Parameter(Mandatory = $false)] [switch] $SendEmail, @@ -15,6 +16,7 @@ param( # so the report stays focused on actual anomalies; intentional exclusions are # admin choices, not gaps. [Parameter(Mandatory = $false)] [switch] $ShowExcludedDevices, + # Kept so existing scripts keep working; has no effect. Use -Verbose for timings. [Parameter(Mandatory = $false)] [switch] $DebugMode ) From 4daa495f51649b5a14b0175b7b6dcf00b9102c67 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 25 Oct 2026 23:10:21 +0100 Subject: [PATCH 14/24] fix(core): BitLocker and LAPS resolvers work when a tenant has no such policy Co-Authored-By: Claude Sonnet 5.5 --- Tests/IntuneAnomalyCore.Tests.ps1 | 26 ++++++++++++++++++++++++++ module/Core/IntuneAnomalyResolvers.ps1 | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Tests/IntuneAnomalyCore.Tests.ps1 b/Tests/IntuneAnomalyCore.Tests.ps1 index 11091c8..ac72bf3 100644 --- a/Tests/IntuneAnomalyCore.Tests.ps1 +++ b/Tests/IntuneAnomalyCore.Tests.ps1 @@ -505,6 +505,32 @@ Describe 'Resolvers' { @($r[$k].Rows)[0].Status | Should -BeLike 'Intentionally excluded*' } } + It 'BitLocker and LAPS resolvers work when the tenant has no such policy (empty lists)' { + $r = InModuleScope RKSolutions { + $ctx = [pscustomobject]@{ + EntraDeviceIdByName = @{} + GroupDeviceAadIds = @{} + GroupUserUpns = @{} + Filters = @{} + BitLockerPolicies = [System.Collections.Generic.List[object]]::new() + BitLockerKeyDeviceIds = [System.Collections.Generic.HashSet[string]]::new() + LapsPolicies = [System.Collections.Generic.List[object]]::new() + LapsCredentialByDeviceId = @{} + LapsCredentialByDeviceName = @{} + } + $d = [pscustomobject]@{ + TenantName = 'T' + Devices = @([pscustomobject]@{ DeviceName = 'N1'; AzureAdDeviceId = 'aad1'; PrimaryUser = 'u@t'; Serialnumber = 'S'; Encrypted = $false; IsCloudPC = $false }) + SecurityKeyContext = $ctx + Warnings = @() + } + @{ BitLocker = (Resolve-IntuneBitLockerAnomalies -Data $d); Laps = (Resolve-IntuneLapsAnomalies -Data $d) } + } + @($r.BitLocker.Rows)[0].Severity | Should -Be 'Critical' + @($r.BitLocker.Rows)[0].Status | Should -Be 'Device not encrypted and no BitLocker policy assigned' + @($r.Laps.Rows)[0].Severity | Should -Be 'Warning' + @($r.Laps.Rows)[0].Status | Should -Be 'No Entra-backed LAPS policy assigned' + } It 'Deprecated: reads display names from the expanded setting definitions' { $r = InModuleScope RKSolutions { $policy = [pscustomobject]@{ id = 'p1'; name = 'Pol'; platforms = 'windows10'; technologies = 'mdm'; settings = @( diff --git a/module/Core/IntuneAnomalyResolvers.ps1 b/module/Core/IntuneAnomalyResolvers.ps1 index c9f0e0a..7b39c11 100644 --- a/module/Core/IntuneAnomalyResolvers.ps1 +++ b/module/Core/IntuneAnomalyResolvers.ps1 @@ -121,7 +121,7 @@ function Get-DeviceAppliedPolicies { an opt-in flag. #> param( - [Parameter(Mandatory)] [array] $Policies, + [Parameter(Mandatory)] [AllowEmptyCollection()] [array] $Policies, [Parameter(Mandatory)] [PSCustomObject] $Device, [Parameter(Mandatory)] [PSCustomObject] $Context ) From 505c49608884149fb841cd40e3fd7fe63ae07c1e Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 25 Oct 2026 23:11:07 +0100 Subject: [PATCH 15/24] ci: open a sync pull request in the Maester pack repository when module/Core changes Co-Authored-By: Claude Opus 5.5 --- .github/workflows/sync-maester-pack.yml | 61 +++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/sync-maester-pack.yml diff --git a/.github/workflows/sync-maester-pack.yml b/.github/workflows/sync-maester-pack.yml new file mode 100644 index 0000000..60c18ae --- /dev/null +++ b/.github/workflows/sync-maester-pack.yml @@ -0,0 +1,61 @@ +# Copies module/Core into royklo/maester-pack-rksolutions (intune-anomalies/helpers/core) and opens or updates +# one pull request there, with the pack's patch version raised. The pack's own CI checks it before it can be merged. +# Needs the secret PACK_SYNC_TOKEN: a fine-grained token with Contents and Pull requests write on that repository only. +name: Sync Maester pack + +on: + push: + branches: [main] + paths: ['module/Core/**'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: sync-maester-pack + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + path: module + + - uses: actions/checkout@v5 + with: + repository: royklo/maester-pack-rksolutions + token: ${{ secrets.PACK_SYNC_TOKEN }} + path: pack + + - name: Copy the core and raise the version + shell: pwsh + run: | + $commit = git -C module rev-parse --short HEAD + ./pack/tools/Sync-Core.ps1 -ModuleRoot ./module -Commit $commit + if (-not (git -C pack status --porcelain)) { Write-Output 'The core is already in sync.'; return } + $manifestPath = 'pack/intune-anomalies/maester-pack.json' + $manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json + $v = [version]$manifest.Version + $manifest.Version = "$($v.Major).$($v.Minor).$($v.Build + 1)" + $manifest | ConvertTo-Json -Depth 10 | Set-Content $manifestPath -Encoding utf8NoBOM + "COMMIT=$commit" >> $env:GITHUB_ENV + "VERSION=$($manifest.Version)" >> $env:GITHUB_ENV + + - name: Open or update the pull request + if: env.COMMIT != '' + shell: pwsh + env: + GH_TOKEN: ${{ secrets.PACK_SYNC_TOKEN }} + run: | + Set-Location pack + git config user.name 'rksolutions-sync' + git config user.email 'rksolutions-sync@users.noreply.github.com' + git checkout -B sync/rksolutions-core + git add intune-anomalies + git commit -m "chore(intune-anomalies): sync core from RKSolutions-Module@$env:COMMIT" + git push --force origin sync/rksolutions-core + $open = gh pr list --head sync/rksolutions-core --state open --json number --jq '.[0].number' + if ($open) { gh pr edit $open --title "Sync core from RKSolutions-Module@$env:COMMIT ($env:VERSION)" } + else { gh pr create --base main --head sync/rksolutions-core --title "Sync core from RKSolutions-Module@$env:COMMIT ($env:VERSION)" --body "Copied module/Core from royklo/RKSolutions-Module@$env:COMMIT into intune-anomalies/helpers/core and raised the pack to $env:VERSION. Add a CHANGELOG.md entry before merging." } From a23ca69f9081d678cf52514a76ef69ae8257a31e Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 25 Oct 2026 23:18:43 +0100 Subject: [PATCH 16/24] ci: sync workflow fails on git errors, keeps a CHANGELOG entry and syncs only Core changes Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/sync-maester-pack.yml | 38 ++++++++++++++++++------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/.github/workflows/sync-maester-pack.yml b/.github/workflows/sync-maester-pack.yml index 60c18ae..8395eb4 100644 --- a/.github/workflows/sync-maester-pack.yml +++ b/.github/workflows/sync-maester-pack.yml @@ -1,5 +1,5 @@ # Copies module/Core into royklo/maester-pack-rksolutions (intune-anomalies/helpers/core) and opens or updates -# one pull request there, with the pack's patch version raised. The pack's own CI checks it before it can be merged. +# one pull request there, with the pack's patch version raised and a CHANGELOG entry added. The pack's own CI checks it before it can be merged. # Needs the secret PACK_SYNC_TOKEN: a fine-grained token with Contents and Pull requests write on that repository only. name: Sync Maester pack @@ -22,6 +22,7 @@ jobs: - uses: actions/checkout@v5 with: path: module + fetch-depth: 0 - uses: actions/checkout@v5 with: @@ -32,16 +33,28 @@ jobs: - name: Copy the core and raise the version shell: pwsh run: | - $commit = git -C module rev-parse --short HEAD + $PSNativeCommandUseErrorActionPreference = $true + # The commit that last changed Core, so a run without Core changes leaves the copied files as they are. + $commit = git -C module log -1 --format=%h -- module/Core ./pack/tools/Sync-Core.ps1 -ModuleRoot ./module -Commit $commit if (-not (git -C pack status --porcelain)) { Write-Output 'The core is already in sync.'; return } - $manifestPath = 'pack/intune-anomalies/maester-pack.json' - $manifest = Get-Content $manifestPath -Raw | ConvertFrom-Json - $v = [version]$manifest.Version - $manifest.Version = "$($v.Major).$($v.Minor).$($v.Build + 1)" - $manifest | ConvertTo-Json -Depth 10 | Set-Content $manifestPath -Encoding utf8NoBOM + $utf8 = [System.Text.UTF8Encoding]::new($false) + $manifestPath = Join-Path $PWD 'pack/intune-anomalies/maester-pack.json' + $text = [System.IO.File]::ReadAllText($manifestPath) + $v = [version](($text | ConvertFrom-Json).Version) + $version = "$($v.Major).$($v.Minor).$($v.Build + 1)" + # Replace the value in the text so the rest of the manifest stays byte for byte as it was. + $new = $text -replace '("Version"\s*:\s*")[^"]+(")', "`${1}$version`${2}" + if ($new -ceq $text) { throw "Could not raise the Version in $manifestPath." } + [System.IO.File]::WriteAllText($manifestPath, $new, $utf8) + $changelogPath = Join-Path $PWD 'pack/CHANGELOG.md' + $log = [System.IO.File]::ReadAllText($changelogPath) + $nl = if ($log.Contains("`r`n")) { "`r`n" } else { "`n" } + $entry = "## intune-anomalies $version$nl$nl- Core synced from RKSolutions-Module@$commit.$nl$nl" + $log = $log -replace '(?m)\A(# Changelog[ \t]*\r?\n\r?\n)', ('${1}' + $entry.Replace('$', '$$')) + [System.IO.File]::WriteAllText($changelogPath, $log, $utf8) "COMMIT=$commit" >> $env:GITHUB_ENV - "VERSION=$($manifest.Version)" >> $env:GITHUB_ENV + "VERSION=$version" >> $env:GITHUB_ENV - name: Open or update the pull request if: env.COMMIT != '' @@ -49,13 +62,16 @@ jobs: env: GH_TOKEN: ${{ secrets.PACK_SYNC_TOKEN }} run: | + $PSNativeCommandUseErrorActionPreference = $true Set-Location pack git config user.name 'rksolutions-sync' git config user.email 'rksolutions-sync@users.noreply.github.com' git checkout -B sync/rksolutions-core - git add intune-anomalies + git add intune-anomalies CHANGELOG.md git commit -m "chore(intune-anomalies): sync core from RKSolutions-Module@$env:COMMIT" git push --force origin sync/rksolutions-core + $title = "Sync core from RKSolutions-Module@$env:COMMIT ($env:VERSION)" + $body = "Copied module/Core from royklo/RKSolutions-Module@$env:COMMIT into intune-anomalies/helpers/core and raised the pack to $env:VERSION. A CHANGELOG.md entry was added automatically; edit it after the last sync and before merging." $open = gh pr list --head sync/rksolutions-core --state open --json number --jq '.[0].number' - if ($open) { gh pr edit $open --title "Sync core from RKSolutions-Module@$env:COMMIT ($env:VERSION)" } - else { gh pr create --base main --head sync/rksolutions-core --title "Sync core from RKSolutions-Module@$env:COMMIT ($env:VERSION)" --body "Copied module/Core from royklo/RKSolutions-Module@$env:COMMIT into intune-anomalies/helpers/core and raised the pack to $env:VERSION. Add a CHANGELOG.md entry before merging." } + if ($open) { gh pr edit $open --title $title --body $body } + else { gh pr create --base main --head sync/rksolutions-core --title $title --body $body } From b52fe572dc41cf4b6274835d419816c2c87d811b Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 25 Oct 2026 23:33:37 +0100 Subject: [PATCH 17/24] fix(core): unreadable keys or LAPS passwords give Unknown instead of false anomalies; settings read falls back to a direct GET BitLocker and LAPS no longer flag every device when the keys or credentials cannot be read. A policy settings read that $batch answers with something other than 200 or 404 is retried as a direct GET. Null child lists no longer break the deprecated settings walker, and a device that fails projection is reported as a General warning. Co-Authored-By: Claude Sonnet 5.5 --- Tests/IntuneAnomalyCore.Tests.ps1 | 79 ++++++++++++++++++++++++++ module/Core/IntuneAnomalyCollector.ps1 | 10 +++- module/Core/IntuneAnomalyResolvers.ps1 | 18 +++--- module/Core/RKCoreGraph.ps1 | 5 +- 4 files changed, 100 insertions(+), 12 deletions(-) diff --git a/Tests/IntuneAnomalyCore.Tests.ps1 b/Tests/IntuneAnomalyCore.Tests.ps1 index ac72bf3..2075177 100644 --- a/Tests/IntuneAnomalyCore.Tests.ps1 +++ b/Tests/IntuneAnomalyCore.Tests.ps1 @@ -545,3 +545,82 @@ Describe 'Resolvers' { @($r.Rows)[0].DetectionSource | Should -Be 'CatalogDisplayName' } } + +Describe 'Unreadable keys, settings fallback and null children' { + It 'BitLocker gives Unknown, and no missing-key anomaly, when the keys cannot be read' { + $r = InModuleScope RKSolutions { + $policy = [pscustomobject]@{ DisplayName = 'P'; Source = 'Catalog'; Assignments = @([pscustomobject]@{ AssignmentType = 'All Devices' }) } + $ctx = [pscustomobject]@{ + EntraDeviceIdByName = @{}; GroupDeviceAadIds = @{}; GroupUserUpns = @{}; Filters = @{} + BitLockerPolicies = @($policy); BitLockerKeyDeviceIds = [System.Collections.Generic.HashSet[string]]::new() + } + $devices = @( + [pscustomobject]@{ DeviceName = 'Enc'; AzureAdDeviceId = 'a1'; PrimaryUser = 'u@t'; Serialnumber = 'S1'; Encrypted = $true; IsCloudPC = $false } + [pscustomobject]@{ DeviceName = 'Plain'; AzureAdDeviceId = 'a2'; PrimaryUser = 'u@t'; Serialnumber = 'S2'; Encrypted = $false; IsCloudPC = $false }) + $d = [pscustomobject]@{ TenantName = 'T'; Devices = $devices; SecurityKeyContext = $ctx; Warnings = @(); BitLockerKeysReadable = $false } + $d2 = $d.PSObject.Copy(); $d2.BitLockerKeysReadable = $true + @{ Unreadable = (Resolve-IntuneBitLockerAnomalies -Data $d); Readable = (Resolve-IntuneBitLockerAnomalies -Data $d2) } + } + @($r.Readable.Rows | Where-Object DeviceName -eq 'Enc').Status | Should -Be 'Encrypted but no OS-volume key in Entra' + @($r.Unreadable.Rows | Where-Object DeviceName -eq 'Enc') | Should -BeNullOrEmpty + $plain = @($r.Unreadable.Rows | Where-Object DeviceName -eq 'Plain') + $plain.Status | Should -Be 'BitLocker policy assigned but device not encrypted' + $plain.KeyEscrowed | Should -Be 'Unknown' + } + It 'LAPS does not flag a missing or stale backup when the credentials cannot be read' { + $r = InModuleScope RKSolutions { + $policy = [pscustomobject]@{ DisplayName = 'P'; Source = 'Catalog'; Assignments = @([pscustomobject]@{ AssignmentType = 'All Devices' }) } + $ctx = [pscustomobject]@{ + EntraDeviceIdByName = @{}; GroupDeviceAadIds = @{}; GroupUserUpns = @{}; Filters = @{} + LapsPolicies = @($policy); LapsCredentialByDeviceId = @{}; LapsCredentialByDeviceName = @{} + } + $d = [pscustomobject]@{ TenantName = 'T'; Devices = @([pscustomobject]@{ DeviceName = 'N1'; AzureAdDeviceId = 'a1'; PrimaryUser = 'u@t'; Serialnumber = 'S' }); SecurityKeyContext = $ctx; Warnings = @(); LapsCredentialsReadable = $false } + $d2 = $d.PSObject.Copy(); $d2.LapsCredentialsReadable = $true + @{ Unreadable = (Resolve-IntuneLapsAnomalies -Data $d); Readable = (Resolve-IntuneLapsAnomalies -Data $d2) } + } + @($r.Readable.Rows)[0].Status | Should -BeLike '*no credential backed up*' + @($r.Unreadable.Rows) | Should -BeNullOrEmpty + } + It 'Get-BitLockerLapsAssignmentContext reads the settings of a policy directly when the batch answers 400' { + InModuleScope RKSolutions { + Mock Invoke-RKCoreGraphRequest { } + Mock Get-DetailedPolicyAssignments { } + Mock Invoke-RKCoreGraphRequest -ParameterFilter { $Uri -eq 'beta/deviceManagement/configurationPolicies' } { [pscustomobject]@{ id = 'p1'; name = 'BitLocker' } } + Mock Invoke-RKCoreGraphRequest -ParameterFilter { $Uri -like '*configurationPolicies/p1/settings*' -and $All } { + [pscustomobject]@{ settingInstance = [pscustomobject]@{ settingDefinitionId = 'device_vendor_msft_bitlocker_requiredeviceencryption' } } + } + Mock Invoke-RKCoreGraphBatch -ParameterFilter { @($Requests.Url) -like '*/settings?*' } { + [pscustomobject]@{ Id = 'cps:p1'; Status = 400; Body = $null } + } + $w = [System.Collections.Generic.List[object]]::new() + $ctx = Get-BitLockerLapsAssignmentContext -Warnings $w + @($ctx.ConfigurationPolicies[0].settings).Count | Should -Be 1 + @($ctx.BitLockerPolicies.Id) | Should -Be @('p1') + } + } + It 'Get-RKCoreBatchBody puts the Graph error message in the throw' { + { InModuleScope RKSolutions { + $w = [System.Collections.Generic.List[object]]::new() + Get-RKCoreBatchBody -Response ([pscustomobject]@{ Id = 'a'; Status = 400; Body = [pscustomobject]@{ error = [pscustomobject]@{ message = 'Resource not valid' } } }) -What 'policy p1' -Area 'BitLocker' -Warnings $w + } } | Should -Throw '*400*Resource not valid*' + } + It 'Deprecated: a group setting whose children are null does not throw' { + $r = InModuleScope RKSolutions { + $si = '{"@odata.type":"#microsoft.graph.deviceManagementConfigurationGroupSettingCollectionInstance","settingDefinitionId":"x_group","groupSettingCollectionValue":[{"children":null}]}' | ConvertFrom-Json + $si2 = '{"@odata.type":"#microsoft.graph.deviceManagementConfigurationGroupSettingInstance","settingDefinitionId":"y_group","groupSettingValue":{"children":null}}' | ConvertFrom-Json + $policy = [pscustomobject]@{ id = 'p'; name = 'P'; settings = @([pscustomobject]@{ settingInstance = $si }, [pscustomobject]@{ settingInstance = $si2 }) } + $d = [pscustomobject]@{ TenantName = 'T'; Warnings = @(); SecurityKeyContext = [pscustomobject]@{ ConfigurationPolicies = @($policy) } } + Resolve-IntuneDeprecatedAnomalies -Data $d + } + @($r.Rows).Count | Should -Be 0 + } +} + +Describe 'General warnings' { + It 'Inactive carries the General warnings, such as a device that could not be processed' { + $r = InModuleScope RKSolutions { + Resolve-IntuneInactiveDevices -Data ([pscustomobject]@{ Devices = @(); Warnings = @([pscustomobject]@{ Area = 'General'; Message = 'Device X could not be processed' }, [pscustomobject]@{ Area = 'Laps'; Message = 'other' }) }) + } + @($r.Warnings.Message) | Should -Be @('Device X could not be processed') + } +} diff --git a/module/Core/IntuneAnomalyCollector.ps1 b/module/Core/IntuneAnomalyCollector.ps1 index 2a66965..63318ba 100644 --- a/module/Core/IntuneAnomalyCollector.ps1 +++ b/module/Core/IntuneAnomalyCollector.ps1 @@ -381,7 +381,7 @@ function Get-AllDeviceData { NoncompliantAlert = (@($uniqueRules | Where-Object { $_ -notin $FilteredForAlerting } | ForEach-Object { ConvertTo-ComplianceSettingFriendlyName $_ }) | Select-Object -Unique) -join ', ' }) } catch { - Write-Warning "Error processing device $($DeviceData.DeviceName): $_" + $Warnings.Add([pscustomobject]@{ Area = 'General'; Message = "Device $($DeviceData.DeviceName) could not be processed and is left out of every category: $($_.Exception.Message)" }) continue } } @@ -405,7 +405,7 @@ function Get-IntuneAppData { #> [CmdletBinding()] param( - [Parameter(Mandatory)] [string] $TenantName, + [Parameter(Mandatory)] [AllowEmptyString()] [string] $TenantName, [Parameter(Mandatory)] [AllowEmptyCollection()] [System.Collections.Generic.List[object]] $Warnings ) @@ -531,11 +531,15 @@ function Get-BitLockerLapsAssignmentContext { Url = "beta/deviceManagement/configurationPolicies/$($p.id)/settings?`$expand=settingDefinitions" } } + $settingsUrlByPolicyId = @{} + foreach ($r in $settingsRequests) { $settingsUrlByPolicyId[($r.Id -replace '^cps:', '')] = $r.Url } $settingsResponses = Invoke-RKCoreGraphBatch -Requests @($settingsRequests) $settingsByPolicyId = @{} foreach ($resp in $settingsResponses) { - $body = Get-RKCoreBatchBody -Response $resp -What "the settings of policy $($resp.Id -replace '^cps:', '')" -Area 'Settings', 'BitLocker', 'Laps' -Warnings $Warnings; if (-not $body) { continue } $policyId = $resp.Id -replace '^cps:', '' + # Graph's $batch sometimes answers 400 for this sub-route although the single GET works: retry it directly (a failure there throws). + if ($resp.Status -notin 200, 404) { $settingsByPolicyId[$policyId] = @(Invoke-RKCoreGraphRequest -Uri $settingsUrlByPolicyId[$policyId] -All); continue } + $body = Get-RKCoreBatchBody -Response $resp -What "the settings of policy $policyId" -Area 'Settings', 'BitLocker', 'Laps' -Warnings $Warnings; if (-not $body) { continue } $settings = @($body.value) # $expand can make Graph return smaller pages: read the rest, so a policy with many settings is complete. if ($body.'@odata.nextLink') { $settings += @(Invoke-RKCoreGraphRequest -Uri $body.'@odata.nextLink' -All) } diff --git a/module/Core/IntuneAnomalyResolvers.ps1 b/module/Core/IntuneAnomalyResolvers.ps1 index 7b39c11..4edb4dd 100644 --- a/module/Core/IntuneAnomalyResolvers.ps1 +++ b/module/Core/IntuneAnomalyResolvers.ps1 @@ -181,14 +181,14 @@ function Select-RKAnomalyWarning { } function Resolve-IntuneInactiveDevices { - <# .SYNOPSIS Windows devices whose last Intune sync is older than -Days (oldest first). #> + <# .SYNOPSIS Windows devices whose last Intune sync is older than -Days (oldest first). Also carries the General warnings (a device that could not be processed). #> [CmdletBinding()] param([Parameter(Mandatory)] $Data, [int] $Days = 90) $cutoff = (Get-Date).AddDays(-$Days) # Same test as the report always used: a device without LastContact also counts as inactive. $rows = @($Data.Devices | Where-Object { $_.LastContact -lt $cutoff } | Sort-Object LastContact | Select-Object Customer, DeviceName, PrimaryUser, Serialnumber, DeviceManufacturer, DeviceModel, LastContact) - [pscustomobject]@{ Rows = $rows; Warnings = @() } + [pscustomobject]@{ Rows = $rows; Warnings = Select-RKAnomalyWarning -Data $Data -Area 'General' } } function Resolve-IntuneDisabledPrimaryUsers { @@ -258,6 +258,8 @@ function Resolve-IntuneBitLockerAnomalies { param([Parameter(Mandatory)] $Data, [string] $TenantName) if (-not $TenantName) { $TenantName = $Data.TenantName } $Context = $Data.SecurityKeyContext + # Only an explicit $false changes anything: without the keys, "no key in Entra" cannot be told from "not readable". + $keysReadable = $Data.BitLockerKeysReadable -ne $false # Cloud PCs have platform-managed disk encryption. $Devices = @($Data.Devices | Where-Object { -not $_.IsCloudPC }) $out = [System.Collections.Generic.List[PSObject]]::new() @@ -299,7 +301,7 @@ function Resolve-IntuneBitLockerAnomalies { } elseif (-not $isEncrypted -and $applied.Count -eq 0) { $status = 'Device not encrypted and no BitLocker policy assigned'; $severity = 'Critical' } elseif ($applied.Count -eq 0) { $status = 'No BitLocker policy assigned'; $severity = 'Warning' } - elseif ($isEncrypted -and -not $hasKey) { $status = 'Encrypted but no OS-volume key in Entra'; $severity = 'Critical' } + elseif ($isEncrypted -and -not $hasKey -and $keysReadable) { $status = 'Encrypted but no OS-volume key in Entra'; $severity = 'Critical' } elseif (-not $isEncrypted) { $status = 'BitLocker policy assigned but device not encrypted'; $severity = 'Critical' } else { continue } # Healthy. @@ -310,7 +312,7 @@ function Resolve-IntuneBitLockerAnomalies { Serialnumber = $d.Serialnumber IsEncrypted = if ($isEncrypted) { 'Yes' } else { 'No' } AppliedPolicies = if ($applied.Count -gt 0) { ($applied | ForEach-Object { $_.PolicyName }) -join '; ' } else { ($excluded | ForEach-Object { "$($_.PolicyName) [$($_.Reason)]" }) -join '; ' } - KeyEscrowed = if ($hasKey) { 'Yes' } else { 'No' } + KeyEscrowed = if (-not $keysReadable) { 'Unknown' } elseif ($hasKey) { 'Yes' } else { 'No' } Severity = $severity Status = $status }) @@ -335,6 +337,8 @@ function Resolve-IntuneLapsAnomalies { $Devices = @($Data.Devices) $out = [System.Collections.Generic.List[PSObject]]::new() $cutoff = (Get-Date).AddDays(-$MaxBackupAgeDays) + # Only an explicit $false changes anything: without the credentials, "no backup" cannot be told from "not readable". + $credentialsReadable = $Data.LapsCredentialsReadable -ne $false foreach ($d in $Devices) { $aadId = Resolve-DeviceAzureAdDeviceId -Device $d -Context $Context @@ -366,7 +370,7 @@ function Resolve-IntuneLapsAnomalies { $severity = 'Info' } elseif ($applied.Count -eq 0) { $status = 'No Entra-backed LAPS policy assigned'; $severity = 'Warning' } - elseif (-not $entry) { $status = 'LAPS policy assigned but no credential backed up to Entra'; $severity = 'Critical' } + elseif (-not $entry -and $credentialsReadable) { $status = 'LAPS policy assigned but no credential backed up to Entra'; $severity = 'Critical' } elseif ($lastBackup -and $lastBackup -lt $cutoff) { $status = "LAPS backup stale (> $MaxBackupAgeDays days, rotation may be stalled)"; $severity = 'Warning' } else { continue } # Healthy. @@ -488,13 +492,13 @@ function Get-IntuneSettingDeprecations { $type = [string]$SettingInstance.'@odata.type' if ($type -like '*groupSettingCollectionInstance*') { foreach ($child in @($SettingInstance.groupSettingCollectionValue)) { - foreach ($childSetting in @($child.children)) { + foreach ($childSetting in @($child.children | Where-Object { $_ })) { foreach ($h in (Get-IntuneSettingDeprecations -SettingInstance $childSetting -Catalog $Catalog)) { $hits.Add($h) } } } } elseif ($type -like '*groupSettingInstance*') { - foreach ($childSetting in @($SettingInstance.groupSettingValue.children)) { + foreach ($childSetting in @($SettingInstance.groupSettingValue.children | Where-Object { $_ })) { foreach ($h in (Get-IntuneSettingDeprecations -SettingInstance $childSetting -Catalog $Catalog)) { $hits.Add($h) } } } diff --git a/module/Core/RKCoreGraph.ps1 b/module/Core/RKCoreGraph.ps1 index 6ad7efb..5bcbca3 100644 --- a/module/Core/RKCoreGraph.ps1 +++ b/module/Core/RKCoreGraph.ps1 @@ -33,7 +33,7 @@ function Get-RKCoreBatchBody { <# .SYNOPSIS The body of a required batch response. 404 (an object deleted or referenced by a stale assignment) is - empty with a warning per area; any other status other than 200 throws. + empty with a warning per area; any other status other than 200 throws (with Graph's error message when there is one). #> [CmdletBinding()] param( @@ -48,5 +48,6 @@ function Get-RKCoreBatchBody { foreach ($a in $Area) { $Warnings.Add([pscustomobject]@{ Area = $a; Message = "$What was not found (404) and is skipped." }) } return $null } - throw "Reading $What from Microsoft Graph failed with status $($Response.Status)." + $detail = if ($Response.Body.error.message) { ": $($Response.Body.error.message)" } else { '.' } + throw "Reading $What from Microsoft Graph failed with status $($Response.Status)$detail" } From 8569a914f9575f3f6f0478b907ed4c8c6a949c8d Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Sun, 25 Oct 2026 23:34:54 +0100 Subject: [PATCH 18/24] fix: retry dropped connections, quieter duplicates, accurate docs Dropped connections and timeouts are retried, the report shows progress while Graph is read, duplicate notices are merged, and stale comments, the CHANGELOG, the layout sections and PERMISSIONS.md describe the current code. Adds tests for Get-RKIntuneAnomaly. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 8 ++- README.md | 4 +- Tests/IntuneAnomalyCore.Tests.ps1 | 72 +++++++++++++++++++++ docs/PERMISSIONS.md | 38 ++++++++--- module/Core/README.md | 2 +- module/Private/IntuneAnomalies.ps1 | 2 +- module/Private/RKCoreGraphAdapter.ps1 | 6 +- module/Public/Get-IntuneAnomaliesReport.ps1 | 2 + module/README.md | 3 + module/RKSolutions.psm1 | 3 +- 10 files changed, 121 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28f29b4..65a4c4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,13 @@ The format follows [Conventional Commits](https://www.conventionalcommits.org/) - Added `Get-RKIntuneAnomaly`. - App install failures now read per-app install summaries (GET) and the full app list. - Deprecated settings now read setting display names from Microsoft Graph instead of a download. -- A missing BitLocker, LAPS or app permission is now shown as a notice instead of flagging every device. +- A missing BitLocker, LAPS or app permission is now shown as a notice instead of flagging every device: unreadable BitLocker keys show `KeyEscrowed = Unknown` and unreadable LAPS passwords raise no backup anomaly. +- `Get-IntuneAnomaliesReport -DebugMode` has no effect any more; use `-Verbose`. Progress messages moved to `-Verbose` too. +- The report no longer stops silently at 10,000 devices or users. +- Application failures are no longer limited to the top 50 apps, and the failed percentage is failed / (failed + installed + not installed + pending), from each app's install summary. +- Fixed: *Multiple Users* no longer lists devices without an enrollment profile on tenants that have no single-user Autopilot profile. +- A device that cannot be processed, and a Settings Catalog settings read that `$batch` rejects, no longer disappear or stop the report: the device is reported as a notice and the read is retried directly. +- Dropped connections and timeouts are retried like 500 and 502 errors. - Graph errors in required reads now stop the report instead of being silently ignored. ## [1.2.2] - 2026-09-24 diff --git a/README.md b/README.md index ce97070..289edd8 100644 --- a/README.md +++ b/README.md @@ -33,10 +33,12 @@ RKSolutions-Module/ │ ├── RKSolutions.psd1 │ ├── RKSolutions.psm1 │ ├── README.md +│ ├── Core/ # Intune anomaly detection shared with the Maester pack │ ├── Public/ # Exported cmdlets │ └── Private/ # Helpers └── Tests/ - └── Consistency.Tests.ps1 # Pester tests + ├── Consistency.Tests.ps1 # Pester tests + └── IntuneAnomalyCore.Tests.ps1 # Pester tests for module/Core and the Graph adapter ``` ## Prerequisites diff --git a/Tests/IntuneAnomalyCore.Tests.ps1 b/Tests/IntuneAnomalyCore.Tests.ps1 index 2075177..8ae8ad1 100644 --- a/Tests/IntuneAnomalyCore.Tests.ps1 +++ b/Tests/IntuneAnomalyCore.Tests.ps1 @@ -624,3 +624,75 @@ Describe 'General warnings' { @($r.Warnings.Message) | Should -Be @('Device X could not be processed') } } + +Describe 'Dropped connections' { + It 'Invoke-RKCoreGraphRequest retries a failure without a status (dropped connection) once' { + InModuleScope RKSolutions { + $script:calls = 0 + Mock Start-Sleep { } + Mock Invoke-MgGraphRequest { + $script:calls++ + if ($script:calls -eq 1) { throw [System.Exception]::new('connection reset') } + [pscustomobject]@{ value = @(1) } + } + @(Invoke-RKCoreGraphRequest -Uri 'beta/x' -All) | Should -Be @(1) + Should -Invoke Invoke-MgGraphRequest -Times 2 -Exactly + } + } +} + +Describe 'Get-RKIntuneAnomaly' { + BeforeAll { + $script:anomalyData = { + param($Keys = $true, $Laps = $true, $Apps = $true) + $policy = [pscustomobject]@{ DisplayName = 'P'; Source = 'Catalog'; Assignments = @( + [pscustomobject]@{ AssignmentType = 'All Devices' } + [pscustomobject]@{ AssignmentType = 'Group (Exclude)'; GroupId = 'g1' }) } + $ctx = [pscustomobject]@{ + EntraDeviceIdByName = @{}; Filters = @{}; GroupUserUpns = @{} + GroupDeviceAadIds = @{ g1 = [System.Collections.Generic.HashSet[string]]::new([string[]]@('aad1')) } + BitLockerPolicies = @($policy); BitLockerKeyDeviceIds = [System.Collections.Generic.HashSet[string]]::new() + LapsPolicies = @($policy); LapsCredentialByDeviceId = @{}; LapsCredentialByDeviceName = @{} + ConfigurationPolicies = @() + } + [pscustomobject]@{ + TenantName = 'T'; CollectedAt = [datetime]'2026-10-25' + Devices = @([pscustomobject]@{ Customer = 'T'; DeviceName = 'E1'; AzureAdDeviceId = 'aad1'; Encrypted = $false; PrimaryUser = 'u@t'; Serialnumber = 'S'; IsCloudPC = $false; LastContact = Get-Date; DeviceHashUploaded = $true; DeviceAssociated = $false; usersLoggedOnCount = 1; OperatingSystemEdition = 'Pro'; OSFriendlyname = 'W11' }) + Users = @(); AutopilotProfiles = @(); Apps = @(); SecurityKeyContext = $ctx + Warnings = @([pscustomobject]@{ Area = 'BitLocker'; Message = 'same' }, [pscustomobject]@{ Area = 'BitLocker'; Message = 'same' }) + BitLockerKeysReadable = $Keys; LapsCredentialsReadable = $Laps; AppsReadable = $Apps + } + } + } + BeforeEach { + Mock -ModuleName RKSolutions Get-MgContext { [pscustomobject]@{ TenantId = 't' } } + Mock -ModuleName RKSolutions Get-RKIntuneAnomalyData { & $script:anomalyData $false $true $false } + } + It 'returns Info rows only with -ShowExcludedDevices' { + @((Get-RKIntuneAnomaly).BitLocker) | Should -BeNullOrEmpty + @((Get-RKIntuneAnomaly).Laps) | Should -BeNullOrEmpty + @((Get-RKIntuneAnomaly -ShowExcludedDevices).BitLocker).Severity | Should -Be 'Info' + @((Get-RKIntuneAnomaly -ShowExcludedDevices).Laps).Severity | Should -Be 'Info' + } + It 'returns an empty category as an empty array, not $null' { + $a = Get-RKIntuneAnomaly + foreach ($p in 'Noncompliant', 'MissingAutopilot', 'MultiUser', 'AppInstallFailures', 'DeprecatedSettings', 'DisabledPrimaryUser') { + ($null -ne $a.$p) | Should -BeTrue -Because $p + $a.$p -is [array] | Should -BeTrue -Because $p + $a.$p.Count | Should -Be 0 + } + } + It 'removes duplicate warnings' { + @((Get-RKIntuneAnomaly).Warnings).Count | Should -Be 1 + } + It 'passes the Readable flags through' { + $a = Get-RKIntuneAnomaly + $a.BitLockerKeysReadable | Should -BeFalse + $a.LapsCredentialsReadable | Should -BeTrue + $a.AppsReadable | Should -BeFalse + } + It 'throws without a Graph context' { + Mock -ModuleName RKSolutions Get-MgContext { $null } + { Get-RKIntuneAnomaly } | Should -Throw '*Connect-RKGraph*' + } +} diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index ec7a251..73e0dba 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -11,18 +11,18 @@ Use these when registering an app in Azure AD / Entra ID or when calling `Connec | Permission | Used by | |------------|---------| | **AuditLog.Read.All** | Get-EntraAdminRolesReport, Get-M365LicenseAssignmentReport | -| **BitlockerKey.ReadBasic.All** | Get-IntuneAnomaliesReport | +| **BitlockerKey.ReadBasic.All** | Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly | | **CustomSecAttributeAssignment.Read.All** | Get-CustomSecurityAttributesReport | | **CustomSecAttributeDefinition.Read.All** | Get-CustomSecurityAttributesReport | -| **DeviceLocalCredential.ReadBasic.All** | Get-IntuneAnomaliesReport | +| **DeviceLocalCredential.ReadBasic.All** | Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly | | **CloudLicensing.Read** | Get-M365LicenseAssignmentReport | -| **CloudPC.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport | +| **CloudPC.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly | | **Device.Read.All** | Get-IntuneEnrollmentFlowsReport | -| **DeviceManagementApps.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport | -| **DeviceManagementConfiguration.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport | -| **DeviceManagementManagedDevices.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport | -| **DeviceManagementServiceConfig.Read.All** | Get-IntuneAnomaliesReport | -| **Directory.Read.All** | Get-IntuneAnomaliesReport, Get-EntraAdminRolesReport, Get-M365LicenseAssignmentReport | +| **DeviceManagementApps.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly | +| **DeviceManagementConfiguration.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly | +| **DeviceManagementManagedDevices.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly | +| **DeviceManagementServiceConfig.Read.All** | Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly | +| **Directory.Read.All** | Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly, Get-EntraAdminRolesReport, Get-M365LicenseAssignmentReport | | **Group.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-M365LicenseAssignmentReport | | **GroupMember.Read.All** | Get-M365LicenseAssignmentReport | | **Mail.Send** | Get-IntuneAnomaliesReport, Get-EntraAdminRolesReport, Get-M365LicenseAssignmentReport, Get-CustomSecurityAttributesReport (optional, for email report) | @@ -30,8 +30,8 @@ Use these when registering an app in Azure AD / Entra ID or when calling `Connec | **PrivilegedEligibilitySchedule.Read.AzureADGroup** | Get-EntraAdminRolesReport | | **RoleAssignmentSchedule.Read.Directory** | Get-EntraAdminRolesReport | | **RoleManagement.Read.Directory** | Get-EntraAdminRolesReport, Get-M365LicenseAssignmentReport | -| **User.Read** | Get-IntuneAnomaliesReport (minimum for interactive) | -| **User.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport, Get-M365LicenseAssignmentReport, Get-CustomSecurityAttributesReport | +| **User.Read** | Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly (minimum for interactive) | +| **User.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly, Get-M365LicenseAssignmentReport, Get-CustomSecurityAttributesReport | --- @@ -97,6 +97,24 @@ The two new scopes drive the BitLocker key escrow and Windows LAPS backup anomal --- +### Get-RKIntuneAnomaly + +Same as Get-IntuneAnomaliesReport, without Mail.Send (it returns objects and sends no email). + +- User.Read +- User.Read.All +- Group.Read.All +- DeviceManagementManagedDevices.Read.All +- DeviceManagementConfiguration.Read.All +- DeviceManagementServiceConfig.Read.All +- DeviceManagementApps.Read.All +- Directory.Read.All +- CloudPC.Read.All +- BitlockerKey.ReadBasic.All +- DeviceLocalCredential.ReadBasic.All + +--- + ### Get-EntraAdminRolesReport - Directory.Read.All diff --git a/module/Core/README.md b/module/Core/README.md index 9eb418e..f87c6ac 100644 --- a/module/Core/README.md +++ b/module/Core/README.md @@ -1,7 +1,7 @@ # Core The files in this folder are copied into the RKSolutions Maester pack (royklo/maester-pack-rksolutions) by -tools/Sync-Core.ps1. They follow the Maester pack rules: +tools/Sync-Core.ps1 (that script lives in the pack repository). They follow the Maester pack rules: - only function definitions at the top level; script-scope values are created by Initialize-RKIntuneCoreState; - Microsoft Graph is only read, and only through Invoke-RKCoreGraphRequest and Invoke-RKCoreGraphBatch; diff --git a/module/Private/IntuneAnomalies.ps1 b/module/Private/IntuneAnomalies.ps1 index af49608..e4e0f6f 100644 --- a/module/Private/IntuneAnomalies.ps1 +++ b/module/Private/IntuneAnomalies.ps1 @@ -298,7 +298,7 @@ function New-IntuneAnomaliesHTMLReport {
    -
    What we check: Windows apps in Intune with at least one failed installation, taken from the Intune app install status report and sorted by number of failed devices.
    +
    What we check: Windows apps in Intune with at least one failed installation, taken from each app's install summary in Intune and sorted by number of failed devices.
    Filters: diff --git a/module/Public/Get-RKIntuneAnomaly.ps1 b/module/Public/Get-RKIntuneAnomaly.ps1 index 2f960f0..0cb9fba 100644 --- a/module/Public/Get-RKIntuneAnomaly.ps1 +++ b/module/Public/Get-RKIntuneAnomaly.ps1 @@ -3,7 +3,7 @@ Returns the Intune anomalies of the connected tenant as objects, one property per category. .DESCRIPTION - Collects the Windows devices, apps, BitLocker and LAPS state and Settings Catalog policies once, and returns: + Collects the Windows devices, BitLocker and LAPS state and Settings Catalog policies once, reads the failed app installs from the Intune app install status report (top 50 by failed devices), and returns: BitLocker, Laps, Noncompliant, Inactive, DisabledPrimaryUser, MissingAutopilot, MultiUser, AppInstallFailures, DeprecatedSettings and OperatingSystemEditions (rows), plus Warnings and the BitLockerKeysReadable, LapsCredentialsReadable and AppsReadable flags. Get-IntuneAnomaliesReport renders the same object as HTML. @@ -33,6 +33,14 @@ function Get-RKIntuneAnomaly { if (-not (Get-MgContext -ErrorAction SilentlyContinue)) { throw 'Not connected to Microsoft Graph. Run Connect-RKGraph first.' } $data = Get-RKIntuneAnomalyData + # Apps come from the Intune install status report (Get-ApplicationFailures), not from Core. + $appsReadable = $true + $appFailures = @() + try { $appFailures = @(Get-ApplicationFailures -TenantName $data.TenantName) } + catch { + if ((Get-RKCoreGraphErrorStatus -ErrorRecord $_) -notin 401, 403) { throw } + $appsReadable = $false + } $results = [ordered]@{ BitLocker = Resolve-IntuneBitLockerAnomalies -Data $data Laps = Resolve-IntuneLapsAnomalies -Data $data -MaxBackupAgeDays $MaxLapsBackupAgeDays @@ -41,7 +49,7 @@ function Get-RKIntuneAnomaly { DisabledPrimaryUser = Resolve-IntuneDisabledPrimaryUsers -Data $data MissingAutopilot = Resolve-IntuneMissingAutopilotDevices -Data $data MultiUser = Resolve-IntuneMultiUserDevices -Data $data - AppInstallFailures = Resolve-IntuneAppInstallFailures -Data $data + AppInstallFailures = [pscustomobject]@{ Rows = $appFailures } DeprecatedSettings = Resolve-IntuneDeprecatedAnomalies -Data $data } $out = [ordered]@{ TenantName = $data.TenantName; CollectedAt = $data.CollectedAt } @@ -53,6 +61,6 @@ function Get-RKIntuneAnomaly { $out.Warnings = @($results.Values | ForEach-Object { $_.Warnings } | Sort-Object Area, Message -Unique) $out.BitLockerKeysReadable = $data.BitLockerKeysReadable $out.LapsCredentialsReadable = $data.LapsCredentialsReadable - $out.AppsReadable = $data.AppsReadable + $out.AppsReadable = $appsReadable [pscustomobject]$out } diff --git a/module/RKSolutions.psm1 b/module/RKSolutions.psm1 index b35e26d..ded2bcb 100644 --- a/module/RKSolutions.psm1 +++ b/module/RKSolutions.psm1 @@ -42,7 +42,7 @@ if (Test-Path $privatePath) { } # Report-specific private scripts. They do not depend on each other; the list is only the load order. # Get-DetailedPolicyAssignments and Test-IntuneFilter live in Core/IntunePolicyAssignment.ps1. - $domainOrder = @('IntuneEnrollmentFlows.ps1', 'IntuneAnomalies.ps1', 'EntraAdminRoles.ps1', 'M365License.ps1', 'CustomSecurityAttributes.ps1') + $domainOrder = @('IntuneEnrollmentFlows.ps1', 'IntuneAnomalies.ps1', 'Get-ApplicationFailures.ps1', 'EntraAdminRoles.ps1', 'M365License.ps1', 'CustomSecurityAttributes.ps1') foreach ($name in $domainOrder) { $fp = Join-Path $privatePath $name if (Test-Path $fp) { . $fp } From 7ae0669bdd3e2e876d375889ec524db69c51ec95 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Mon, 26 Oct 2026 00:14:20 +0100 Subject: [PATCH 21/24] fix: no temp file left behind when the app list cannot be read; accurate changelog line for apps Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- module/Private/Get-ApplicationFailures.ps1 | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 604b21d..040867d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The format follows [Conventional Commits](https://www.conventionalcommits.org/) - Added `Get-RKIntuneAnomaly`. - Deprecated settings now read setting display names from Microsoft Graph instead of a download. -- A missing BitLocker, LAPS or app permission is now shown as a notice instead of flagging every device: unreadable BitLocker keys show `KeyEscrowed = Unknown` and unreadable LAPS passwords raise no backup anomaly. +- A missing BitLocker or LAPS permission is now shown as a notice instead of flagging every device: unreadable BitLocker keys show `KeyEscrowed = Unknown` and unreadable LAPS passwords raise no backup anomaly. A missing app permission leaves the Application Failures tab empty with a notice instead of stopping the report. - `Get-IntuneAnomaliesReport -DebugMode` has no effect any more; use `-Verbose`. Progress messages moved to `-Verbose` too. - The report no longer stops silently at 10,000 devices or users. - Fixed: *Multiple Users* no longer lists devices without an enrollment profile on tenants that have no single-user Autopilot profile. diff --git a/module/Private/Get-ApplicationFailures.ps1 b/module/Private/Get-ApplicationFailures.ps1 index 13eb5e9..a38fb29 100644 --- a/module/Private/Get-ApplicationFailures.ps1 +++ b/module/Private/Get-ApplicationFailures.ps1 @@ -34,11 +34,12 @@ function Get-ApplicationFailures { } } - # Use a unique temporary file to avoid race conditions - $Data = [System.IO.Path]::GetTempFileName() - $apps = (Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?`$filter=(isof(%27microsoft.graph.win32CatalogApp%27)%20or%20isof(%27microsoft.graph.windowsStoreApp%27)%20or%20isof(%27microsoft.graph.microsoftStoreForBusinessApp%27)%20or%20isof(%27microsoft.graph.officeSuiteApp%27)%20or%20(isof(%27microsoft.graph.win32LobApp%27)%20and%20not(isof(%27microsoft.graph.win32CatalogApp%27)))%20or%20isof(%27microsoft.graph.windowsMicrosoftEdgeApp%27)%20or%20isof(%27microsoft.graph.windowsPhone81AppX%27)%20or%20isof(%27microsoft.graph.windowsPhone81StoreApp%27)%20or%20isof(%27microsoft.graph.windowsPhoneXAP%27)%20or%20isof(%27microsoft.graph.windowsAppX%27)%20or%20isof(%27microsoft.graph.windowsMobileMSI%27)%20or%20isof(%27microsoft.graph.windowsUniversalAppX%27)%20or%20isof(%27microsoft.graph.webApp%27)%20or%20isof(%27microsoft.graph.windowsWebApp%27)%20or%20isof(%27microsoft.graph.winGetApp%27))%20and%20(microsoft.graph.managedApp/appAvailability%20eq%20null%20or%20microsoft.graph.managedApp/appAvailability%20eq%20%27lineOfBusiness%27%20or%20isAssigned%20eq%20true)&`$orderby=displayName&").value + # Use a unique temporary file to avoid race conditions. Created after the app list, so a missing permission + # (which fails that first call) leaves no file behind. + $Data = [System.IO.Path]::GetTempFileName() + $params = @{ Select = @( "DisplayName" From 78b473af89f52176852f48cc324091ce610ef608 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Mon, 26 Oct 2026 05:26:54 +0100 Subject: [PATCH 22/24] refactor: Get-RKIntuneAnomaly is internal; the module exports the same cmdlets as 1.2.2 Get-IntuneAnomaliesReport still uses it to collect the data. The Maester pack does not call it at run time (it carries its own copy of module/Core). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 - README.md | 1 - Tests/IntuneAnomalyCore.Tests.ps1 | 22 +++++------ docs/CMDLET-REFERENCE.md | 21 ---------- docs/PERMISSIONS.md | 38 +++++-------------- .../Get-RKIntuneAnomaly.ps1 | 5 +-- module/README.md | 2 +- module/RKSolutions.psd1 | 1 - module/RKSolutions.psm1 | 3 +- 9 files changed, 24 insertions(+), 70 deletions(-) rename module/{Public => Private}/Get-RKIntuneAnomaly.ps1 (94%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 040867d..6506b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,6 @@ The format follows [Conventional Commits](https://www.conventionalcommits.org/) ## Unreleased -- Added `Get-RKIntuneAnomaly`. - Deprecated settings now read setting display names from Microsoft Graph instead of a download. - A missing BitLocker or LAPS permission is now shown as a notice instead of flagging every device: unreadable BitLocker keys show `KeyEscrowed = Unknown` and unreadable LAPS passwords raise no backup anomaly. A missing app permission leaves the Application Failures tab empty with a notice instead of stopping the report. - `Get-IntuneAnomaliesReport -DebugMode` has no effect any more; use `-Verbose`. Progress messages moved to `-Verbose` too. diff --git a/README.md b/README.md index 289edd8..a3cddf4 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,6 @@ Disconnect-RKGraph | **Disconnect-RKGraph** | Disconnects and clears the Graph session. | | **Get-IntuneEnrollmentFlowsReport** | Generates Intune assignment overview and/or device visualization report. | | **Get-IntuneAnomaliesReport** | Generates Intune anomalies report. | -| **Get-RKIntuneAnomaly** | Returns the Intune anomalies of the connected tenant as objects. | | **Get-EntraAdminRolesReport** | Generates Entra admin roles report. | | **Get-M365LicenseAssignmentReport** | Generates M365 license assignment report. | | **Get-CustomSecurityAttributesReport** | Generates Custom Security Attributes report across users, devices, and apps. | diff --git a/Tests/IntuneAnomalyCore.Tests.ps1 b/Tests/IntuneAnomalyCore.Tests.ps1 index c56b5b1..a07da35 100644 --- a/Tests/IntuneAnomalyCore.Tests.ps1 +++ b/Tests/IntuneAnomalyCore.Tests.ps1 @@ -620,13 +620,13 @@ Describe 'Get-RKIntuneAnomaly' { Mock -ModuleName RKSolutions Get-ApplicationFailures { } } It 'returns Info rows only with -ShowExcludedDevices' { - @((Get-RKIntuneAnomaly).BitLocker) | Should -BeNullOrEmpty - @((Get-RKIntuneAnomaly).Laps) | Should -BeNullOrEmpty - @((Get-RKIntuneAnomaly -ShowExcludedDevices).BitLocker).Severity | Should -Be 'Info' - @((Get-RKIntuneAnomaly -ShowExcludedDevices).Laps).Severity | Should -Be 'Info' + @((InModuleScope RKSolutions { Get-RKIntuneAnomaly }).BitLocker) | Should -BeNullOrEmpty + @((InModuleScope RKSolutions { Get-RKIntuneAnomaly }).Laps) | Should -BeNullOrEmpty + @((InModuleScope RKSolutions { Get-RKIntuneAnomaly -ShowExcludedDevices }).BitLocker).Severity | Should -Be 'Info' + @((InModuleScope RKSolutions { Get-RKIntuneAnomaly -ShowExcludedDevices }).Laps).Severity | Should -Be 'Info' } It 'returns an empty category as an empty array, not $null' { - $a = Get-RKIntuneAnomaly + $a = InModuleScope RKSolutions { Get-RKIntuneAnomaly } foreach ($p in 'Noncompliant', 'MissingAutopilot', 'MultiUser', 'AppInstallFailures', 'DeprecatedSettings', 'DisabledPrimaryUser') { ($null -ne $a.$p) | Should -BeTrue -Because $p $a.$p -is [array] | Should -BeTrue -Because $p @@ -634,17 +634,17 @@ Describe 'Get-RKIntuneAnomaly' { } } It 'removes duplicate warnings' { - @((Get-RKIntuneAnomaly).Warnings).Count | Should -Be 1 + @((InModuleScope RKSolutions { Get-RKIntuneAnomaly }).Warnings).Count | Should -Be 1 } It 'passes the Readable flags through' { - $a = Get-RKIntuneAnomaly + $a = InModuleScope RKSolutions { Get-RKIntuneAnomaly } $a.BitLockerKeysReadable | Should -BeFalse $a.LapsCredentialsReadable | Should -BeTrue $a.AppsReadable | Should -BeTrue } It 'takes AppInstallFailures from Get-ApplicationFailures' { Mock -ModuleName RKSolutions Get-ApplicationFailures { [pscustomobject]@{ Application = 'A'; FailedDeviceCount = 3 } } - $a = Get-RKIntuneAnomaly + $a = InModuleScope RKSolutions { Get-RKIntuneAnomaly } @($a.AppInstallFailures).Application | Should -Be @('A') Should -Invoke Get-ApplicationFailures -ModuleName RKSolutions -ParameterFilter { $TenantName -eq 'T' } } @@ -654,18 +654,18 @@ Describe 'Get-RKIntuneAnomaly' { $e | Add-Member -NotePropertyName Response -NotePropertyValue ([pscustomobject]@{ StatusCode = 403 }) throw $e } - $a = Get-RKIntuneAnomaly + $a = InModuleScope RKSolutions { Get-RKIntuneAnomaly } $a.AppsReadable | Should -BeFalse $a.AppInstallFailures -is [array] | Should -BeTrue $a.AppInstallFailures.Count | Should -Be 0 } It 'throws when the app report fails with another error' { Mock -ModuleName RKSolutions Get-ApplicationFailures { throw 'http 500' } - { Get-RKIntuneAnomaly } | Should -Throw '*http 500*' + { InModuleScope RKSolutions { Get-RKIntuneAnomaly } } | Should -Throw '*http 500*' } It 'throws without a Graph context' { Mock -ModuleName RKSolutions Get-MgContext { $null } - { Get-RKIntuneAnomaly } | Should -Throw '*Connect-RKGraph*' + { InModuleScope RKSolutions { Get-RKIntuneAnomaly } } | Should -Throw '*Connect-RKGraph*' } } diff --git a/docs/CMDLET-REFERENCE.md b/docs/CMDLET-REFERENCE.md index fb64e3c..f949222 100644 --- a/docs/CMDLET-REFERENCE.md +++ b/docs/CMDLET-REFERENCE.md @@ -76,27 +76,6 @@ Connect first with **Connect-RKGraph**; this cmdlet uses the existing connection --- -## Get-RKIntuneAnomaly - -Returns the Intune anomalies of the connected tenant as objects, one property per category. - -Collects the Windows devices, BitLocker and LAPS state and Settings Catalog policies once, reads the failed app installs from the Intune app install status report (top 50 by failed devices), and returns: BitLocker, Laps, Noncompliant, Inactive, DisabledPrimaryUser, MissingAutopilot, MultiUser, AppInstallFailures, DeprecatedSettings and OperatingSystemEditions (rows), plus Warnings and the BitLockerKeysReadable, LapsCredentialsReadable and AppsReadable flags. Get-IntuneAnomaliesReport renders the same object as HTML. - - -| Parameter | Description | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------- | -| **InactiveDays** | A device whose last Intune sync is older than this many days is inactive. Default 90. | -| **MaxLapsBackupAgeDays** | A LAPS backup older than this many days is stale. Default 60. | -| **ShowExcludedDevices** | Also return devices that a BitLocker or LAPS policy leaves out on purpose (exclude group or filter), as Info rows. | - -Connect first with **Connect-RKGraph**; this cmdlet uses the existing connection (no auth parameters). - - -**Examples:** -- `Get-RKIntuneAnomaly | Select-Object -ExpandProperty BitLocker | Export-Csv bitlocker.csv` - ---- - ## Get-EntraAdminRolesReport Generates Entra admin roles report. diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md index 73e0dba..ec7a251 100644 --- a/docs/PERMISSIONS.md +++ b/docs/PERMISSIONS.md @@ -11,18 +11,18 @@ Use these when registering an app in Azure AD / Entra ID or when calling `Connec | Permission | Used by | |------------|---------| | **AuditLog.Read.All** | Get-EntraAdminRolesReport, Get-M365LicenseAssignmentReport | -| **BitlockerKey.ReadBasic.All** | Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly | +| **BitlockerKey.ReadBasic.All** | Get-IntuneAnomaliesReport | | **CustomSecAttributeAssignment.Read.All** | Get-CustomSecurityAttributesReport | | **CustomSecAttributeDefinition.Read.All** | Get-CustomSecurityAttributesReport | -| **DeviceLocalCredential.ReadBasic.All** | Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly | +| **DeviceLocalCredential.ReadBasic.All** | Get-IntuneAnomaliesReport | | **CloudLicensing.Read** | Get-M365LicenseAssignmentReport | -| **CloudPC.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly | +| **CloudPC.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport | | **Device.Read.All** | Get-IntuneEnrollmentFlowsReport | -| **DeviceManagementApps.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly | -| **DeviceManagementConfiguration.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly | -| **DeviceManagementManagedDevices.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly | -| **DeviceManagementServiceConfig.Read.All** | Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly | -| **Directory.Read.All** | Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly, Get-EntraAdminRolesReport, Get-M365LicenseAssignmentReport | +| **DeviceManagementApps.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport | +| **DeviceManagementConfiguration.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport | +| **DeviceManagementManagedDevices.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport | +| **DeviceManagementServiceConfig.Read.All** | Get-IntuneAnomaliesReport | +| **Directory.Read.All** | Get-IntuneAnomaliesReport, Get-EntraAdminRolesReport, Get-M365LicenseAssignmentReport | | **Group.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-M365LicenseAssignmentReport | | **GroupMember.Read.All** | Get-M365LicenseAssignmentReport | | **Mail.Send** | Get-IntuneAnomaliesReport, Get-EntraAdminRolesReport, Get-M365LicenseAssignmentReport, Get-CustomSecurityAttributesReport (optional, for email report) | @@ -30,8 +30,8 @@ Use these when registering an app in Azure AD / Entra ID or when calling `Connec | **PrivilegedEligibilitySchedule.Read.AzureADGroup** | Get-EntraAdminRolesReport | | **RoleAssignmentSchedule.Read.Directory** | Get-EntraAdminRolesReport | | **RoleManagement.Read.Directory** | Get-EntraAdminRolesReport, Get-M365LicenseAssignmentReport | -| **User.Read** | Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly (minimum for interactive) | -| **User.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport, Get-RKIntuneAnomaly, Get-M365LicenseAssignmentReport, Get-CustomSecurityAttributesReport | +| **User.Read** | Get-IntuneAnomaliesReport (minimum for interactive) | +| **User.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport, Get-M365LicenseAssignmentReport, Get-CustomSecurityAttributesReport | --- @@ -97,24 +97,6 @@ The two new scopes drive the BitLocker key escrow and Windows LAPS backup anomal --- -### Get-RKIntuneAnomaly - -Same as Get-IntuneAnomaliesReport, without Mail.Send (it returns objects and sends no email). - -- User.Read -- User.Read.All -- Group.Read.All -- DeviceManagementManagedDevices.Read.All -- DeviceManagementConfiguration.Read.All -- DeviceManagementServiceConfig.Read.All -- DeviceManagementApps.Read.All -- Directory.Read.All -- CloudPC.Read.All -- BitlockerKey.ReadBasic.All -- DeviceLocalCredential.ReadBasic.All - ---- - ### Get-EntraAdminRolesReport - Directory.Read.All diff --git a/module/Public/Get-RKIntuneAnomaly.ps1 b/module/Private/Get-RKIntuneAnomaly.ps1 similarity index 94% rename from module/Public/Get-RKIntuneAnomaly.ps1 rename to module/Private/Get-RKIntuneAnomaly.ps1 index 0cb9fba..0190495 100644 --- a/module/Public/Get-RKIntuneAnomaly.ps1 +++ b/module/Private/Get-RKIntuneAnomaly.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Returns the Intune anomalies of the connected tenant as objects, one property per category. + Private: returns the Intune anomalies of the connected tenant as objects, one property per category. .DESCRIPTION Collects the Windows devices, BitLocker and LAPS state and Settings Catalog policies once, reads the failed app installs from the Intune app install status report (top 50 by failed devices), and returns: @@ -17,9 +17,6 @@ .PARAMETER ShowExcludedDevices Also return devices that a BitLocker or LAPS policy leaves out on purpose (exclude group or filter), as Info rows. - -.EXAMPLE - Get-RKIntuneAnomaly | Select-Object -ExpandProperty BitLocker | Export-Csv bitlocker.csv #> function Get-RKIntuneAnomaly { [CmdletBinding()] diff --git a/module/README.md b/module/README.md index 38677ce..efe4b43 100644 --- a/module/README.md +++ b/module/README.md @@ -17,7 +17,6 @@ module/ │ ├── Disconnect-RKGraph.ps1 │ ├── Get-IntuneEnrollmentFlowsReport.ps1 │ ├── Get-IntuneAnomaliesReport.ps1 -│ ├── Get-RKIntuneAnomaly.ps1 │ ├── Get-EntraAdminRolesReport.ps1 │ ├── Get-M365LicenseAssignmentReport.ps1 │ └── Get-CustomSecurityAttributesReport.ps1 @@ -33,6 +32,7 @@ module/ ├── Get-RKSolutionsReportTemplate.ps1 ├── IntuneEnrollmentFlows.ps1 ├── IntuneAnomalies.ps1 + ├── Get-RKIntuneAnomaly.ps1 ├── EntraAdminRoles.ps1 ├── M365License.ps1 └── CustomSecurityAttributes.ps1 diff --git a/module/RKSolutions.psd1 b/module/RKSolutions.psd1 index e8780fe..7036899 100644 --- a/module/RKSolutions.psd1 +++ b/module/RKSolutions.psd1 @@ -13,7 +13,6 @@ 'Disconnect-RKGraph', 'Get-IntuneEnrollmentFlowsReport', 'Get-IntuneAnomaliesReport', - 'Get-RKIntuneAnomaly', 'Get-EntraAdminRolesReport', 'Get-M365LicenseAssignmentReport', 'Get-CustomSecurityAttributesReport' diff --git a/module/RKSolutions.psm1 b/module/RKSolutions.psm1 index ded2bcb..10476e7 100644 --- a/module/RKSolutions.psm1 +++ b/module/RKSolutions.psm1 @@ -42,7 +42,7 @@ if (Test-Path $privatePath) { } # Report-specific private scripts. They do not depend on each other; the list is only the load order. # Get-DetailedPolicyAssignments and Test-IntuneFilter live in Core/IntunePolicyAssignment.ps1. - $domainOrder = @('IntuneEnrollmentFlows.ps1', 'IntuneAnomalies.ps1', 'Get-ApplicationFailures.ps1', 'EntraAdminRoles.ps1', 'M365License.ps1', 'CustomSecurityAttributes.ps1') + $domainOrder = @('IntuneEnrollmentFlows.ps1', 'IntuneAnomalies.ps1', 'Get-ApplicationFailures.ps1', 'Get-RKIntuneAnomaly.ps1', 'EntraAdminRoles.ps1', 'M365License.ps1', 'CustomSecurityAttributes.ps1') foreach ($name in $domainOrder) { $fp = Join-Path $privatePath $name if (Test-Path $fp) { . $fp } @@ -68,7 +68,6 @@ Export-ModuleMember -Function @( 'Disconnect-RKGraph', 'Get-IntuneEnrollmentFlowsReport', 'Get-IntuneAnomaliesReport', - 'Get-RKIntuneAnomaly', 'Get-EntraAdminRolesReport', 'Get-M365LicenseAssignmentReport', 'Get-CustomSecurityAttributesReport' From c19e19a8882ba81a6d5405baacbf0dce1e93fcf9 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Mon, 26 Oct 2026 05:32:01 +0100 Subject: [PATCH 23/24] chore: release 1.3.0 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 28 ++++++++++++++++++++-------- module/RKSolutions.psd1 | 3 ++- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6506b25..0e407dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,26 @@ The format follows [Conventional Commits](https://www.conventionalcommits.org/) ## Unreleased -- Deprecated settings now read setting display names from Microsoft Graph instead of a download. -- A missing BitLocker or LAPS permission is now shown as a notice instead of flagging every device: unreadable BitLocker keys show `KeyEscrowed = Unknown` and unreadable LAPS passwords raise no backup anomaly. A missing app permission leaves the Application Failures tab empty with a notice instead of stopping the report. -- `Get-IntuneAnomaliesReport -DebugMode` has no effect any more; use `-Verbose`. Progress messages moved to `-Verbose` too. -- The report no longer stops silently at 10,000 devices or users. -- Fixed: *Multiple Users* no longer lists devices without an enrollment profile on tenants that have no single-user Autopilot profile. -- A device that cannot be processed, and a Settings Catalog settings read that `$batch` rejects, no longer disappear or stop the report: the device is reported as a notice and the read is retried directly. -- Dropped connections and timeouts are retried like 500 and 502 errors. -- Graph errors in required reads now stop the report instead of being silently ignored. +## [1.3.0] - 2026-10-26 + +Release for the Intune Anomalies report. + +### Changes + +- **Missing BitLocker, LAPS or app permissions show a notice** instead of flagging every device or stopping the report. Unreadable BitLocker keys show `KeyEscrowed = Unknown`. +- **Failed required Graph reads stop the report** instead of producing a half-empty one. +- **`-DebugMode` has no effect any more.** Use `-Verbose`, which now also shows the progress messages. +- **Deprecated settings take their names from Microsoft Graph** instead of a download. + +### Fixes + +- **A tenant without a BitLocker or LAPS policy no longer crashes the report.** +- **The report no longer stops silently at 10,000 devices or users.** +- ***Multiple Users* no longer lists devices without an enrollment profile** on tenants without a single-user Autopilot profile. +- **One device that cannot be processed no longer stops the report**; it is listed as a notice. +- **Dropped connections, timeouts and rejected `$batch` reads are retried.** + +--- ## [1.2.2] - 2026-09-24 diff --git a/module/RKSolutions.psd1 b/module/RKSolutions.psd1 index 7036899..a270428 100644 --- a/module/RKSolutions.psd1 +++ b/module/RKSolutions.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'RKSolutions.psm1' - ModuleVersion = '1.2.2' + ModuleVersion = '1.3.0' GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' Author = 'Roy Klooster' CompanyName = 'RK Solutions' @@ -26,6 +26,7 @@ LicenseUri = 'https://opensource.org/licenses/MIT' ProjectUri = 'https://github.com/royklo/RKSolutions-Module' ReleaseNotes = @' +1.3.0 - Intune Anomalies: a tenant without a BitLocker or LAPS policy no longer crashes the report, missing BitLocker, LAPS or app permissions show a notice instead of flagging every device or stopping the report, and the report no longer stops silently at 10,000 devices or users. Failed required Graph reads now stop the report. -DebugMode has no effect any more; use -Verbose. 1.2.2 - Intune Anomalies: recognises Autopilot device preparation (v2), leaves Cloud PCs out of the BitLocker and Autopilot tabs, explains on every tab what it checks, and shows noncompliant reasons with their Intune admin-center names. Fixed "Unknown" noncompliant reasons and application failure percentages that were 100 times too small. 1.2.1 - Intune Anomalies: fixed missing noncompliant reasons and a ~60s hang on tenants with noncompliant devices. Long values now wrap inside table cells in every report. 1.2.0 - Intune Anomalies: added BitLocker key escrow, Windows LAPS backup and deprecated Settings Catalog settings detection, and a much faster report via Graph $batch. Added BitlockerKey.ReadBasic.All and DeviceLocalCredential.ReadBasic.All scopes. From bc0fa118310373f8c1aaa323689d321002cc167a Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Mon, 26 Oct 2026 06:13:27 +0100 Subject: [PATCH 24/24] fix: look up the device type of every device again; permission notices say access denied Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- Tests/IntuneAnomalyCore.Tests.ps1 | 15 +++++++++++++++ module/Core/IntuneAnomalyCollector.ps1 | 16 +++++++--------- module/Public/Get-IntuneAnomaliesReport.ps1 | 6 +++--- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e407dd..381c5bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ Release for the Intune Anomalies report. ### Changes -- **Missing BitLocker, LAPS or app permissions show a notice** instead of flagging every device or stopping the report. Unreadable BitLocker keys show `KeyEscrowed = Unknown`. +- **Access denied on BitLocker keys, LAPS credentials or apps shows a notice** instead of flagging every device or stopping the report. Unreadable BitLocker keys show `KeyEscrowed = Unknown`. - **Failed required Graph reads stop the report** instead of producing a half-empty one. - **`-DebugMode` has no effect any more.** Use `-Verbose`, which now also shows the progress messages. - **Deprecated settings take their names from Microsoft Graph** instead of a download. diff --git a/Tests/IntuneAnomalyCore.Tests.ps1 b/Tests/IntuneAnomalyCore.Tests.ps1 index a07da35..3c7df50 100644 --- a/Tests/IntuneAnomalyCore.Tests.ps1 +++ b/Tests/IntuneAnomalyCore.Tests.ps1 @@ -242,6 +242,21 @@ Describe 'Collector logic' { $w.Count | Should -Be 0 } } + It 'Get-AllDeviceData marks an encrypted Cloud PC that has an Autopilot record as IsCloudPC' { + InModuleScope RKSolutions { + Mock Invoke-RKCoreGraphRequest { } + Mock Invoke-RKCoreGraphBatch { } + Mock Invoke-RKCoreGraphRequest -ParameterFilter { $Uri -like 'beta/deviceManagement/managedDevices?*' } { + [pscustomobject]@{ id = 'c1'; deviceName = 'CPC1'; serialNumber = 'S9'; isEncrypted = $true; complianceState = 'compliant'; managementAgent = 'mdm' } + } + Mock Invoke-RKCoreGraphRequest -ParameterFilter { $Uri -eq 'beta/deviceManagement/windowsAutopilotDeviceIdentities' } { [pscustomobject]@{ serialNumber = 'S9' } } + Mock Invoke-RKCoreGraphBatch -ParameterFilter { @($Requests.Url) -like '*?$select=id,deviceType' } { + [pscustomobject]@{ Id = 'dt:c1'; Status = 200; Body = [pscustomobject]@{ id = 'c1'; deviceType = 'cloudPC' } } + } + $w = [System.Collections.Generic.List[object]]::new() + @(Get-AllDeviceData -Users @() -TenantName 'T' -Warnings $w)[0].IsCloudPC | Should -BeTrue + } + } It 'Get-AllDeviceData asks once for the settingStates of a policy state listed twice' { InModuleScope RKSolutions { Mock Invoke-RKCoreGraphRequest { } diff --git a/module/Core/IntuneAnomalyCollector.ps1 b/module/Core/IntuneAnomalyCollector.ps1 index 840aa31..3909960 100644 --- a/module/Core/IntuneAnomalyCollector.ps1 +++ b/module/Core/IntuneAnomalyCollector.ps1 @@ -142,14 +142,12 @@ function Get-AllDeviceData { if ($ci.importedDeviceIdentifier) { [void]$CorporateIdentifierSerials.Add(($ci.importedDeviceIdentifier -split ',')[-1].Trim()) } } - # Only devices that BitLocker or Autopilot would flag need their deviceType: the list endpoint always reports - # 'windowsRT', and only a per-device GET returns 'cloudPC' (Windows 365 and Dev Box). + # The list endpoint always reports 'windowsRT'; only a per-device GET returns 'cloudPC' (Windows 365 and Dev Box). + # BitLocker and Autopilot both skip Cloud PCs, so every device needs its deviceType. + # ponytail: one deviceType read per device (exact, as 1.2.2). In the pack each is a separate GET; if large tenants get slow, narrow to the devices BitLocker or Autopilot would flag once the BitLocker context is known. $swStep.Restart() - $candidates = @($AllDeviceData | Where-Object { - -not $_.isEncrypted -or (-not $AutopilotLookup.ContainsKey([string]$_.serialNumber) -and -not ($_.serialNumber -and $CorporateIdentifierSerials.Contains([string]$_.serialNumber))) - }) $CloudPcIds = [System.Collections.Generic.HashSet[string]]::new() - $deviceTypeRequests = @(foreach ($d in $candidates) { [pscustomobject]@{ Id = "dt:$($d.id)"; Url = "beta/deviceManagement/managedDevices/$($d.id)?`$select=id,deviceType" } }) + $deviceTypeRequests = @(foreach ($d in $AllDeviceData) { [pscustomobject]@{ Id = "dt:$($d.id)"; Url = "beta/deviceManagement/managedDevices/$($d.id)?`$select=id,deviceType" } }) $failedLookups = 0 foreach ($resp in @(Invoke-RKCoreGraphBatch -Requests $deviceTypeRequests)) { if ($resp.Status -ne 200) { $failedLookups++; continue } @@ -159,7 +157,7 @@ function Get-AllDeviceData { foreach ($area in 'BitLocker', 'Autopilot') { $Warnings.Add([pscustomobject]@{ Area = $area; Message = "The device type of $failedLookups device(s) could not be read; they are treated as physical devices, so a Cloud PC among them can be listed." }) } } $swStep.Stop() - Write-Verbose ("[Get-AllDeviceData] Device type lookup: {0:N2}s ({1} candidates, {2} Cloud PCs)" -f $swStep.Elapsed.TotalSeconds, $candidates.Count, $CloudPcIds.Count) + Write-Verbose ("[Get-AllDeviceData] Device type lookup: {0:N2}s ({1} devices, {2} Cloud PCs)" -f $swStep.Elapsed.TotalSeconds, $AllDeviceData.Count, $CloudPcIds.Count) # Pre-build user lookup hashtable (id -> UPN) for O(1) lookups $UserLookup = @{} @@ -648,7 +646,7 @@ function Get-BitLockerLapsAssignmentContext { } # 6. BitLocker recovery keys. OS-volume key (volumeType == 1) is what protects the system drive. - # A missing scope (BitlockerKey.ReadBasic.All, 401/403) sets BitLockerKeysReadable instead of + # Access denied (401/403: the scope BitlockerKey.ReadBasic.All or a role is missing) sets BitLockerKeysReadable instead of # reading as an empty key list; any other error throws. Write-Verbose ' Fetching BitLocker recovery key index (OS volume)...' $bitLockerFetched = 0 @@ -669,7 +667,7 @@ function Get-BitLockerLapsAssignmentContext { # 7. LAPS local credentials directory. Entry's `id` field IS the Azure AD device id # (verified against live Graph). Key the hashtable by it directly. - # A missing scope (DeviceLocalCredential.ReadBasic.All, 401/403) sets LapsCredentialsReadable. + # Access denied (401/403: the scope DeviceLocalCredential.ReadBasic.All or a role is missing) sets LapsCredentialsReadable. Write-Verbose ' Fetching directory/deviceLocalCredentials (Windows LAPS)...' $lapsFetched = 0 try { diff --git a/module/Public/Get-IntuneAnomaliesReport.ps1 b/module/Public/Get-IntuneAnomaliesReport.ps1 index cdf69bd..4dfddf4 100644 --- a/module/Public/Get-IntuneAnomaliesReport.ps1 +++ b/module/Public/Get-IntuneAnomaliesReport.ps1 @@ -35,9 +35,9 @@ try { $tenantname = $anomaly.TenantName $notices = [System.Collections.Generic.List[string]]::new() - if (-not $anomaly.BitLockerKeysReadable) { $notices.Add('BitLocker recovery keys could not be read (BitlockerKey.ReadBasic.All is missing). The BitLocker tab cannot tell which keys are in Entra ID.') } - if (-not $anomaly.LapsCredentialsReadable) { $notices.Add('Windows LAPS credentials could not be read (DeviceLocalCredential.ReadBasic.All is missing). The LAPS tab cannot tell which devices backed up a password.') } - if (-not $anomaly.AppsReadable) { $notices.Add('Apps could not be read (DeviceManagementApps.Read.All is missing). The Application Failures tab is empty.') } + if (-not $anomaly.BitLockerKeysReadable) { $notices.Add('BitLocker recovery keys could not be read (access denied). Check that the connection has BitlockerKey.ReadBasic.All and that the account has a role that may read them, such as Global Reader. The BitLocker tab cannot tell which keys are in Entra ID.') } + if (-not $anomaly.LapsCredentialsReadable) { $notices.Add('Windows LAPS credentials could not be read (access denied). Check that the connection has DeviceLocalCredential.ReadBasic.All and that the account has a role that may read them, such as Global Reader. The LAPS tab cannot tell which devices backed up a password.') } + if (-not $anomaly.AppsReadable) { $notices.Add('Apps could not be read (access denied). Check that the connection has DeviceManagementApps.Read.All and that the account has an Intune role that may read apps, such as Global Reader. The Application Failures tab is empty.') } foreach ($w in $anomaly.Warnings) { $notices.Add("$($w.Area): $($w.Message)") } # A warning recorded under several areas has the same text. $notices = [System.Collections.Generic.List[string]]@($notices | Select-Object -Unique)