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/.github/workflows/sync-maester-pack.yml b/.github/workflows/sync-maester-pack.yml new file mode 100644 index 0000000..8395eb4 --- /dev/null +++ b/.github/workflows/sync-maester-pack.yml @@ -0,0 +1,77 @@ +# 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 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 + +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 + fetch-depth: 0 + + - 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: | + $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 } + $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=$version" >> $env:GITHUB_ENV + + - name: Open or update the pull request + if: env.COMMIT != '' + shell: pwsh + 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 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 $title --body $body } + else { gh pr create --base main --head sync/rksolutions-core --title $title --body $body } diff --git a/CHANGELOG.md b/CHANGELOG.md index 069243e..381c5bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,29 @@ The format follows [Conventional Commits](https://www.conventionalcommits.org/) +## Unreleased + +## [1.3.0] - 2026-10-26 + +Release for the Intune Anomalies report. + +### Changes + +- **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. + +### 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 Patch release for the Intune Anomalies report. diff --git a/README.md b/README.md index d4a0ee8..a3cddf4 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/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 new file mode 100644 index 0000000..3c7df50 --- /dev/null +++ b/Tests/IntuneAnomalyCore.Tests.ps1 @@ -0,0 +1,712 @@ +# 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-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 { + $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 '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() + 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 -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' } } + (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)" } } } + $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' + } + } +} + +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 + } + } +} + +Describe 'Collector error kinds' { + 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 { $_.Message -like '*group g1*' }).Area | Should -Be @('BitLocker', 'Laps') + $ctx.GroupUserUpns['g1'].Contains('user@contoso.com') | Should -BeTrue + } + } + 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' + } + } +} + +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 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 { } + 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 + } + } +} + +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' } }) + SecurityKeyContext = [pscustomobject]@{ ConfigurationPolicies = @() } + Warnings = @([pscustomobject]@{ Area = 'Compliance'; Message = 'w1' }) + } + } + } + + 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 '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 = @(); Users = @(); AutopilotProfiles = @() } + @( + (Resolve-IntuneNoncompliantDevices -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 '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 = @( + [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' + } +} + +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 'Every device check carries the General warnings, the deprecated check does not' { + $r = InModuleScope RKSolutions { + $d = [pscustomobject]@{ TenantName = 'T'; Devices = @(); Users = @(); AutopilotProfiles = @(); Warnings = @([pscustomobject]@{ Area = 'General'; Message = 'Device X could not be processed' }, [pscustomobject]@{ Area = 'Zzz'; Message = 'other' }) + SecurityKeyContext = [pscustomobject]@{ ConfigurationPolicies = @(); BitLockerPolicies = @(); LapsPolicies = @(); BitLockerKeyDeviceIds = [System.Collections.Generic.HashSet[string]]::new(); LapsCredentialByDeviceId = @{}; LapsCredentialByDeviceName = @{}; EntraDeviceIdByName = @{}; GroupDeviceAadIds = @{}; GroupUserUpns = @{}; Filters = @{} } } + $out = @{} + foreach ($f in 'BitLocker', 'Laps', 'Noncompliant', 'Inactive', 'DisabledPrimaryUsers', 'MissingAutopilotDevices', 'MultiUserDevices', 'Deprecated') { + $cmd = (Get-Command "Resolve-Intune*$f*" | Select-Object -First 1).Name + $out[$f] = @((& $cmd -Data $d).Warnings.Message) + } + $out + } + foreach ($f in 'BitLocker', 'Laps', 'Noncompliant', 'Inactive', 'DisabledPrimaryUsers', 'MissingAutopilotDevices', 'MultiUserDevices') { $r[$f] | Should -Be @('Device X could not be processed') -Because $f } + foreach ($f in 'Deprecated') { $r[$f] | Should -BeNullOrEmpty -Because $f } + } +} + +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) + $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 = @(); SecurityKeyContext = $ctx + Warnings = @([pscustomobject]@{ Area = 'BitLocker'; Message = 'same' }, [pscustomobject]@{ Area = 'BitLocker'; Message = 'same' }) + BitLockerKeysReadable = $Keys; LapsCredentialsReadable = $Laps + } + } + } + BeforeEach { + Mock -ModuleName RKSolutions Get-MgContext { [pscustomobject]@{ TenantId = 't' } } + Mock -ModuleName RKSolutions Get-RKIntuneAnomalyData { & $script:anomalyData $false $true } + Mock -ModuleName RKSolutions Get-ApplicationFailures { } + } + It 'returns Info rows only with -ShowExcludedDevices' { + @((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 = 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 + $a.$p.Count | Should -Be 0 + } + } + It 'removes duplicate warnings' { + @((InModuleScope RKSolutions { Get-RKIntuneAnomaly }).Warnings).Count | Should -Be 1 + } + It 'passes the Readable flags through' { + $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 = InModuleScope RKSolutions { Get-RKIntuneAnomaly } + @($a.AppInstallFailures).Application | Should -Be @('A') + Should -Invoke Get-ApplicationFailures -ModuleName RKSolutions -ParameterFilter { $TenantName -eq 'T' } + } + It 'gives AppsReadable $false and an empty array when the app report answers 403' { + Mock -ModuleName RKSolutions Get-ApplicationFailures { + $e = [System.Exception]::new('http 403') + $e | Add-Member -NotePropertyName Response -NotePropertyValue ([pscustomobject]@{ StatusCode = 403 }) + throw $e + } + $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' } + { InModuleScope RKSolutions { Get-RKIntuneAnomaly } } | Should -Throw '*http 500*' + } + It 'throws without a Graph context' { + Mock -ModuleName RKSolutions Get-MgContext { $null } + { InModuleScope RKSolutions { Get-RKIntuneAnomaly } } | Should -Throw '*Connect-RKGraph*' + } +} + +Describe 'Cached policies and failed batch requests' { + It 'Get-BitLockerLapsAssignmentContext leaves the policy objects the adapter returned untouched' { + InModuleScope RKSolutions { + $script:cached = [pscustomobject]@{ id = 'p1'; name = 'BitLocker' } + Mock Invoke-RKCoreGraphRequest { } + Mock Get-DetailedPolicyAssignments { } + Mock Invoke-RKCoreGraphRequest -ParameterFilter { $Uri -eq 'beta/deviceManagement/configurationPolicies' } { $script:cached } + Mock Invoke-RKCoreGraphBatch -ParameterFilter { @($Requests.Url) -like '*/settings?*' } { + $s = [pscustomobject]@{ settingInstance = [pscustomobject]@{ settingDefinitionId = 'device_vendor_msft_bitlocker_requiredeviceencryption' } } + [pscustomobject]@{ Id = 'cps:p1'; Status = 200; Body = [pscustomobject]@{ value = @($s) } } + } + $w = [System.Collections.Generic.List[object]]::new() + $ctx = Get-BitLockerLapsAssignmentContext -Warnings $w + $script:cached.PSObject.Properties['settings'] | Should -BeNullOrEmpty + @($ctx.ConfigurationPolicies[0].settings).Count | Should -Be 1 + @($ctx.BitLockerPolicies.Id) | Should -Be @('p1') + } + } + It 'Invoke-RKCoreGraphBatch carries the error text of a failed request into Body.error.message' { + InModuleScope RKSolutions { + Mock Invoke-RKGraphBatch { [pscustomobject]@{ Id = 'a'; Status = 0; Body = $null; Headers = $null; Error = 'connection reset' } } + $r = @(Invoke-RKCoreGraphBatch -Requests @([pscustomobject]@{ Id = 'a'; Url = 'beta/x' })) + $r[0].Body.error.message | Should -Be 'connection reset' + } + } +} diff --git a/docs/CMDLET-REFERENCE.md b/docs/CMDLET-REFERENCE.md index bfda429..f949222 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). 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/IntuneAnomalyCollector.ps1 b/module/Core/IntuneAnomalyCollector.ps1 new file mode 100644 index 0000000..3909960 --- /dev/null +++ b/module/Core/IntuneAnomalyCollector.ps1 @@ -0,0 +1,745 @@ +function Get-AllDeviceData { + [CmdletBinding()] + param( + # Entra member users (id, userPrincipalName), for the logged-on users lookup. + [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Users, + [Parameter(Mandatory)] [AllowEmptyString()] [string] $TenantName, + [Parameter(Mandatory)] [AllowEmptyCollection()] [System.Collections.Generic.List[object]] $Warnings + ) + + 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-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) + + # Get all AutoPilot registered devices under "Enrollment" + Write-Verbose "Fetching Autopilot devices..." + $swStep.Restart() + $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[[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-RKCoreGraphRequest -Uri 'beta/deviceManagement/importedDeviceIdentities' -All) + foreach ($ci in $CorporateIdentifiers) { + if ($ci.importedDeviceIdentifier) { [void]$CorporateIdentifierSerials.Add(($ci.importedDeviceIdentifier -split ',')[-1].Trim()) } + } + + # 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() + $CloudPcIds = [System.Collections.Generic.HashSet[string]]::new() + $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 } + 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} devices, {2} Cloud PCs)" -f $swStep.Elapsed.TotalSeconds, $AllDeviceData.Count, $CloudPcIds.Count) + + # Pre-build user lookup hashtable (id -> UPN) for O(1) lookups + $UserLookup = @{} + foreach ($u in $Users) { + 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-RKCoreGraphRequest -Uri 'beta/deviceManagement/settings').deviceComplianceCheckinThresholdDays + if ($checkinDays) { $script:ComplianceSettingFriendlyNames['RequireRemainContact'] = "Is active (no compliance check-in in the last $checkinDays days)" } + } catch { + $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-Verbose "Fetching compliance details for $($NonCompliantDevices.Count) noncompliant devices..." + + $policyStateRequests = foreach ($d in $NonCompliantDevices) { + [PSCustomObject]@{ + Id = "ps:$($d.id)" + Url = "beta/deviceManagement/managedDevices/$($d.id)/deviceCompliancePolicyStates" + } + } + + $swStep.Restart() + $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) + + # 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 = "beta/deviceManagement/managedDevices/$deviceId/deviceCompliancePolicyStates/$($s.id)/settingStates" + }) + } + } + + if ($settingStatePairs.Count -gt 0) { + $swStep.Restart() + $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) + + $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-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 } + 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) + } + } + + # 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) 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 + $results = [System.Collections.Generic.List[PSObject]]::new() + $totalDevices = $AllDeviceData.Count + + Write-Verbose "Processing $totalDevices devices..." + $swStep.Restart() + + for ($i = 0; $i -lt $AllDeviceData.Count; $i++) { + $DeviceData = $AllDeviceData[$i] + + 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[[string]$DeviceData.SerialNumber] + $HashUploaded = $AutopilotLookup.ContainsKey([string]$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)) + # 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 } + 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 { + $Warnings.Add([pscustomobject]@{ Area = 'General'; Message = "Device $($DeviceData.DeviceName) could not be processed and is left out of every category: $($_.Exception.Message)" }) + continue + } + } + + $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-Verbose "Device processing completed!" + Write-Verbose "Processed $($results.Count) devices out of $totalDevices total devices" + return $results + +} + +function Get-AutopilotProfilesInformation { + @(Invoke-RKCoreGraphRequest -Uri 'beta/deviceManagement/windowsAutopilotDeploymentProfiles' -All) +} + +# ---------------------------------------------------------------------------- +# 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. +# 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. +# ---------------------------------------------------------------------------- + +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, + [Parameter(Mandatory)] [AllowEmptyCollection()] [System.Collections.Generic.List[object]] $Warnings + ) + + $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) + # 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). + 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. + # 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) + $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 = "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) { + $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) } + $settingsByPolicyId[$policyId] = $settings + } + # Attach settings to copies, so the objects the adapter returned (possibly a shared cache) stay untouched. + $configPolicies = @(foreach ($p in $configPolicies) { + $s = $null + if ($settingsByPolicyId.ContainsKey($p.id)) { $s = $settingsByPolicyId[$p.id] } + $copy = $p.PSObject.Copy() + if ($copy.PSObject.Properties['settings']) { $copy.settings = $s } + else { $copy | Add-Member -NotePropertyName settings -NotePropertyValue $s -Force } + $copy + }) + $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 -ThrowOnError + $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 -ThrowOnError + $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-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) + }) + } + } + + # 4. Discover Endpoint Security intents (BitLocker disk encryption template). + # 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) + }) + } + } + + # 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-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) + $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 = "v1.0/groups/$gid/transitiveMembers/microsoft.graph.device?`$select=id,deviceId" + }) + $memberRequests.Add([PSCustomObject]@{ + Id = "gu:$gid" + Url = "v1.0/groups/$gid/transitiveMembers/microsoft.graph.user?`$select=id,userPrincipalName" + }) + } + # 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) { + $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)) { + 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 ($body.'@odata.nextLink') { + $nextLinkFollowups.Add([PSCustomObject]@{ Gid = $gid; IsUser = $isUser; NextUri = [string]$body.'@odata.nextLink' }) + } + } + foreach ($f in $nextLinkFollowups) { + $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) } + } + } + } + } + + # 6. BitLocker recovery keys. OS-volume key (volumeType == 1) is what protects the system drive. + # 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 + try { + 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 { + if ((Get-RKCoreGraphErrorStatus -ErrorRecord $_) -in 401, 403) { + $context.BitLockerKeysReadable = $false + $context.BitLockerKeysError = $_.Exception.Message + } + 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. + # 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 { + 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 + } + } + Write-Verbose " Indexed $lapsFetched LAPS local credential record(s)." + } + catch { + if ((Get-RKCoreGraphErrorStatus -ErrorRecord $_) -in 401, 403) { + $context.LapsCredentialsReadable = $false + $context.LapsCredentialsError = $_.Exception.Message + } + 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-Verbose ' Building Entra device name -> deviceId safety map...' + try { + $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 + } + } + 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)" }) } + } + + 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?$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 + + [pscustomobject]@{ + TenantId = [string]$organization.id + TenantName = $tenantName + CollectedAt = Get-Date + Devices = $devices + Users = $users + AutopilotProfiles = $profiles + SecurityKeyContext = $context + BitLockerKeysReadable = $context.BitLockerKeysReadable + BitLockerKeysError = $context.BitLockerKeysError + LapsCredentialsReadable = $context.LapsCredentialsReadable + LapsCredentialsError = $context.LapsCredentialsError + Warnings = $warnings.ToArray() + } +} diff --git a/module/Core/IntuneAnomalyResolvers.ps1 b/module/Core/IntuneAnomalyResolvers.ps1 new file mode 100644 index 0000000..6e73ec6 --- /dev/null +++ b/module/Core/IntuneAnomalyResolvers.ps1 @@ -0,0 +1,547 @@ +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 Core/IntunePolicyAssignment.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)] [AllowEmptyCollection()] [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 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). 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 = Select-RKAnomalyWarning -Data $Data -Area 'General' } +} + +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 = Select-RKAnomalyWarning -Data $Data -Area 'General' } +} + +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', 'General' } +} + +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 = Select-RKAnomalyWarning -Data $Data -Area 'General' } +} + +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', 'General' } +} + +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. Those devices still come back, as Info rows. + #> + [CmdletBinding()] + 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() + 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, surfaced as Info. + $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 -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. + + $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 (-not $keysReadable) { 'Unknown' } elseif ($hasKey) { 'Yes' } else { 'No' } + Severity = $severity + Status = $status + }) + } + [pscustomobject]@{ Rows = $out.ToArray(); Warnings = Select-RKAnomalyWarning -Data $Data -Area 'BitLocker', 'General' } +} + +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 + surfaced as Info rows. + #> + [CmdletBinding()] + 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) + # 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 + 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) { + $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 -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. + + $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 + }) + } + [pscustomobject]@{ Rows = $out.ToArray(); Warnings = Select-RKAnomalyWarning -Data $Data -Area 'Laps', 'General' } +} + +# ---------------------------------------------------------------------------- +# 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" +# 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 { + <# + 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 | Where-Object { $_ })) { + foreach ($h in (Get-IntuneSettingDeprecations -SettingInstance $childSetting -Catalog $Catalog)) { $hits.Add($h) } + } + } + } + elseif ($type -like '*groupSettingInstance*') { + foreach ($childSetting in @($SettingInstance.groupSettingValue.children | Where-Object { $_ })) { + 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 data object, scans + every setting against the expanded setting definitions, and emits one + row per deprecated setting in use. + #> + [CmdletBinding()] + param([Parameter(Mandatory)] $Data, [string] $TenantName) + if (-not $TenantName) { $TenantName = $Data.TenantName } + $Context = $Data.SecurityKeyContext + + 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) { + 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-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' } +} diff --git a/module/Core/IntunePolicyAssignment.ps1 b/module/Core/IntunePolicyAssignment.ps1 new file mode 100644 index 0000000..8ac7ce9 --- /dev/null +++ b/module/Core/IntunePolicyAssignment.ps1 @@ -0,0 +1,226 @@ +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 { + if ($ThrowOnError -and (Get-RKCoreGraphErrorStatus -ErrorRecord $_) -ne 404) { throw } + 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/Core/README.md b/module/Core/README.md new file mode 100644 index 0000000..f87c6ac --- /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 (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; +- 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/Core/RKCoreGraph.ps1 b/module/Core/RKCoreGraph.ps1 new file mode 100644 index 0000000..5bcbca3 --- /dev/null +++ b/module/Core/RKCoreGraph.ps1 @@ -0,0 +1,53 @@ +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 (-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 +} + +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 (with Graph's error message when there is one). + #> + [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) { + foreach ($a in $Area) { $Warnings.Add([pscustomobject]@{ Area = $a; Message = "$What was not found (404) and is skipped." }) } + return $null + } + $detail = if ($Response.Body.error.message) { ": $($Response.Body.error.message)" } else { '.' } + throw "Reading $What from Microsoft Graph failed with status $($Response.Status)$detail" +} diff --git a/module/Private/Get-ApplicationFailures.ps1 b/module/Private/Get-ApplicationFailures.ps1 new file mode 100644 index 0000000..a38fb29 --- /dev/null +++ b/module/Private/Get-ApplicationFailures.ps1 @@ -0,0 +1,131 @@ +# Private: Windows apps with failed installs, from the Intune app install status report (top 50 by failed devices). +function Get-ApplicationFailures { + [CmdletBinding()] + param([Parameter(Mandatory)] [AllowEmptyString()] [string] $TenantName) + + # 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 + } + } + } + } + + $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" + "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 +} diff --git a/module/Private/Get-RKIntuneAnomaly.ps1 b/module/Private/Get-RKIntuneAnomaly.ps1 new file mode 100644 index 0000000..0190495 --- /dev/null +++ b/module/Private/Get-RKIntuneAnomaly.ps1 @@ -0,0 +1,63 @@ +<# +.SYNOPSIS + 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: + 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. +#> +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 + # 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 + 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 = [pscustomobject]@{ Rows = $appFailures } + 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 = $appsReadable + [pscustomobject]$out +} diff --git a/module/Private/IntuneAnomalies.ps1 b/module/Private/IntuneAnomalies.ps1 index a14d72e..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
    @@ -1293,1379 +1302,3 @@ function New-IntuneAnomaliesHTMLReport { catch { Write-Host "Report saved to: $ExportPath (could not open automatically)." -ForegroundColor Yellow } } } - - -# 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])) { - 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. -# ---------------------------------------------------------------------------- - -$script:BitLockerSettingPrefix = 'device_vendor_msft_bitlocker_' -$script:LapsSettingPrefix = 'device_vendor_msft_laps_policies_' -$script:LapsBackupDirectorySetting = 'device_vendor_msft_laps_policies_backupdirectory' - -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 -} diff --git a/module/Private/IntuneEnrollmentFlows.ps1 b/module/Private/IntuneEnrollmentFlows.ps1 index 0db5aeb..4e15bef 100644 --- a/module/Private/IntuneEnrollmentFlows.ps1 +++ b/module/Private/IntuneEnrollmentFlows.ps1 @@ -1,21 +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 = @( 'AadRegistered', 'hardwareInformation', 'ActivationLockBypassCode', 'AndroidSecurityPatchLevel', 'AssignmentFilterEvaluationStatusDetails', @@ -264,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) @@ -1346,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. diff --git a/module/Private/RKCoreGraphAdapter.ps1 b/module/Private/RKCoreGraphAdapter.ps1 new file mode 100644 index 0000000..70f6315 --- /dev/null +++ b/module/Private/RKCoreGraphAdapter.ps1 @@ -0,0 +1,71 @@ +# 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, 502 and a dropped connection or timeout (no status) 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 0, 500, 502) { throw } + Start-Sleep -Seconds $delays[$attempt] + } + } +} + +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-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-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' + } + $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 -Activity 'Reading Intune data from Microsoft Graph')) { + # A request that failed without an answer from Graph has no Body but an Error: give it the shape Get-RKCoreBatchBody reads. + $body = $response.Body + if ($null -eq $body -and $response.PSObject.Properties['Error'] -and $response.Error) { $body = [pscustomobject]@{ error = [pscustomobject]@{ message = [string]$response.Error } } } + [pscustomobject]@{ Id = [string]$response.Id; Status = [int]$response.Status; Body = $body } + } + } +} diff --git a/module/Public/Get-IntuneAnomaliesReport.ps1 b/module/Public/Get-IntuneAnomaliesReport.ps1 index e1ead83..4dfddf4 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 ) @@ -26,68 +28,23 @@ 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 (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) + 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/README.md b/module/README.md index 137b9bf..efe4b43 100644 --- a/module/README.md +++ b/module/README.md @@ -11,6 +11,7 @@ module/ ├── RKSolutions.psd1 # Module manifest ├── RKSolutions.psm1 # Root script (dot-sources Public + Private) ├── README.md # This file +├── Core/ # Intune anomaly detection shared with the Maester pack (see Core/README.md) ├── Public/ # Exported cmdlets │ ├── Connect-RKGraph.ps1 │ ├── Disconnect-RKGraph.ps1 @@ -23,6 +24,7 @@ module/ ├── Connect-ToMgGraph.ps1 ├── Invoke-RKSolutionsWithConnection.ps1 ├── Invoke-GraphRequestWithPaging.ps1 + ├── RKCoreGraphAdapter.ps1 ├── Send-EmailWithAttachment.ps1 ├── ConvertTo-DateString.ps1 ├── Install-Requirements.ps1 @@ -30,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 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. diff --git a/module/RKSolutions.psm1 b/module/RKSolutions.psm1 index 545d9b0..10476e7 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', @@ -39,16 +40,22 @@ if (Test-Path $privatePath) { $fp = Join-Path $privatePath $name 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. - $domainOrder = @('IntuneEnrollmentFlows.ps1', 'IntuneAnomalies.ps1', 'EntraAdminRoles.ps1', 'M365License.ps1', 'CustomSecurityAttributes.ps1') + # 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', 'Get-RKIntuneAnomaly.ps1', 'EntraAdminRoles.ps1', 'M365License.ps1', 'CustomSecurityAttributes.ps1') foreach ($name in $domainOrder) { $fp = Join-Path $privatePath $name if (Test-Path $fp) { . $fp } } } +# 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) {