diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8687e6c..c1964dc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,46 @@ All notable changes to this project will be documented in this file.
The format follows [Conventional Commits](https://www.conventionalcommits.org/) and this project adheres to [Semantic Versioning](https://semver.org/). Release notes for each version are also generated from git history by the automation pipeline using the same conventional types (feat, fix, docs, refactor, test, etc.).
+
+
+## [1.2.0] - 2026-06-17
+
+Adds BitLocker, Windows LAPS, and deprecated-settings detection to `Get-IntuneAnomaliesReport`, and significantly speeds the report up via Graph `$batch`.
+
+### New detections
+
+- **BitLocker key escrow** — flags devices where Intune assigned a BitLocker policy but no OS-volume recovery key is in Entra, plus devices that aren't encrypted at all. Resolves assignments across Settings Catalog, legacy device configurations, and Endpoint Security intents, honouring include / exclude groups and assignment filters.
+- **Windows LAPS backup** — flags devices covered by an Entra-backed LAPS policy with no local admin credential backed up, or whose most recent backup is older than 60 days.
+- **Deprecated Settings Catalog settings** — walks every Settings Catalog policy in the tenant and flags settings Microsoft has marked deprecated. Catalog data is cached.
+- New `-ShowExcludedDevices` switch surfaces devices that were deliberately excluded from BitLocker / LAPS policies (via exclude group or assignment filter) as Info-severity rows. Off by default.
+
+### Performance
+
+End-to-end report runtime on a 75-policy / 2-device tenant dropped from ~19s to ~8s. Larger tenants see proportionally bigger wins.
+
+The main lever is a new private Graph `$batch` helper used across five hot paths (compliance fetch, Settings Catalog policy `/settings`, app `?$expand=assignments`, group `transitiveMembers`, and the prior `$expand=settings` paginated fetch). Two quadratic per-row lookups in the BitLocker/LAPS and app-failure code paths were also replaced with hashtable indexes.
+
+### Fixes
+
+- DataTables didn't always initialise correctly in the rendered report — fixed.
+- The two quadratic lookups above no longer slow down the report at higher device counts.
+
+### Permissions
+
+The Intune Anomalies report now requires two additional Graph scopes to check BitLocker and LAPS state:
+
+- `BitlockerKey.ReadBasic.All`
+- `DeviceLocalCredential.ReadBasic.All`
+
+Both are the `ReadBasic` variants — the report can confirm that a recovery key or password backup exists, but never reads the actual recovery password or local admin password. `Connect-RKGraph` auto-detects missing scopes on existing tokens and re-grants consent for you.
+
+### Polish
+
+- BitLocker and LAPS tabs drop the Manufacturer, Model, and Policy Assigned columns (irrelevant to the security finding each row describes). Inventory-focused tabs keep them.
+- Every stat tile in the report now uses a distinct color.
+
+---
+
## [1.1.0]
### Features
diff --git a/Tests/Consistency.Tests.ps1 b/Tests/Consistency.Tests.ps1
index 46c73d0..cb54372 100644
--- a/Tests/Consistency.Tests.ps1
+++ b/Tests/Consistency.Tests.ps1
@@ -100,3 +100,170 @@ Describe 'No-silent-failure contract' {
{ Disconnect-RKGraph } | Should -Not -Throw
}
}
+
+Describe 'Public report cmdlet shape contracts' {
+ # AST-level checks: every public report cmdlet must follow the standard shape
+ # (CmdletBinding, the five contract parameters, a connection check).
+
+ BeforeAll {
+ Remove-Module -Name 'RKSolutions' -ErrorAction SilentlyContinue
+ Import-Module (Get-RKSolutionsManifestPath) -Force
+ $script:reportCmdlets = @(
+ 'Get-IntuneEnrollmentFlowsReport',
+ 'Get-IntuneAnomaliesReport',
+ 'Get-EntraAdminRolesReport',
+ 'Get-M365LicenseAssignmentReport',
+ 'Get-CustomSecurityAttributesReport'
+ )
+ }
+
+ It 'Every report cmdlet declares an output-path parameter (ExportPath or OutputPath)' {
+ # Reports don't share a uniform Send/From/Recipient surface (Get-IntuneEnrollmentFlowsReport
+ # uses its own ExportToCsv/ExportFolder model). What IS uniform: every report must let the
+ # caller direct the rendered HTML somewhere, via either -ExportPath or -OutputPath.
+ foreach ($name in $script:reportCmdlets) {
+ $cmd = Get-Command -Name $name -ErrorAction Stop
+ $paramNames = $cmd.Parameters.Keys
+ $hasOutputParam = ($paramNames -contains 'ExportPath') -or ($paramNames -contains 'OutputPath')
+ $hasOutputParam | Should -BeTrue -Because "$name must let the caller direct the output via -ExportPath or -OutputPath"
+ }
+ }
+
+ It 'Every report cmdlet uses [CmdletBinding()] so -Verbose works' {
+ foreach ($name in $script:reportCmdlets) {
+ $cmd = Get-Command -Name $name -ErrorAction Stop
+ $cmd.CmdletBinding | Should -BeTrue -Because "$name must declare [CmdletBinding()] to honor -Verbose"
+ }
+ }
+
+ It 'Every report cmdlet checks Get-MgContext before doing work' {
+ # Source-level check: each public cmdlet file must reference Get-MgContext (the connection guard).
+ $publicDir = Join-Path (Split-Path (Get-RKSolutionsManifestPath) -Parent) 'Public'
+ foreach ($name in $script:reportCmdlets) {
+ $file = Join-Path $publicDir "$name.ps1"
+ (Test-Path $file) | Should -BeTrue -Because "expected $file to exist"
+ $content = Get-Content -Raw -Path $file
+ $content | Should -Match 'Get-MgContext' -Because "$name must guard with Get-MgContext before any Graph call"
+ }
+ }
+}
+
+Describe 'BitLocker / LAPS anomaly resolver output contracts' {
+ # The HTML template emits
cells for each property in a specific order;
+ # if these resolvers ever stop emitting one of these columns, the rendered
+ # report will silently lose a value. These tests run with synthetic input
+ # (no Microsoft Graph) and assert the exact column set + order.
+
+ BeforeAll {
+ Remove-Module -Name 'RKSolutions' -ErrorAction SilentlyContinue
+ Import-Module (Get-RKSolutionsManifestPath) -Force
+ }
+
+ It 'Resolve-IntuneBitLockerAnomalies emits the documented column set in order' {
+ $row = InModuleScope RKSolutions {
+ $ctx = [PSCustomObject]@{
+ BitLockerPolicies = [System.Collections.Generic.List[object]]::new()
+ LapsPolicies = [System.Collections.Generic.List[object]]::new()
+ BitLockerKeyDeviceIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ GroupDeviceAadIds = @{}
+ GroupUserUpns = @{}
+ GroupUserIds = @{}
+ Filters = @{}
+ EntraDeviceIdByName = @{}
+ }
+ $d = [PSCustomObject]@{
+ DeviceName = 'TEST-DEVICE-1'
+ AzureAdDeviceId = $null # Force the "no aadId, not encrypted" branch -> produces a row
+ Encrypted = $false
+ PrimaryUser = 'user@test.local'
+ Serialnumber = 'TEST-SN-1'
+ DeviceManufacturer = 'TestCorp'
+ DeviceModel = 'TestModel'
+ }
+ $rows = Resolve-IntuneBitLockerAnomalies -Devices @($d) -Context $ctx -TenantName 'TestTenant'
+ $rows[0]
+ }
+ $row | Should -Not -BeNullOrEmpty -Because 'an unencrypted device with no aadId must surface as an anomaly row'
+ $actualColumns = @($row.PSObject.Properties.Name)
+ $expectedColumns = @('Customer','DeviceName','PrimaryUser','Serialnumber','IsEncrypted','AppliedPolicies','KeyEscrowed','Severity','Status')
+ $actualColumns | Should -Be $expectedColumns -Because 'BitLocker anomaly rows must match the HTML template
order (Severity column comes before Status)'
+ }
+
+ It 'Resolve-IntuneLapsAnomalies emits the documented column set in order' {
+ $row = InModuleScope RKSolutions {
+ # Create a LAPS policy that matches via "All Devices" so the synthetic device hits the
+ # "policy assigned but no credential" branch (which produces an anomaly row).
+ $policy = [PSCustomObject]@{
+ Id = 'fake-policy-id'
+ DisplayName = 'Fake LAPS Policy'
+ Source = 'SettingsCatalog'
+ BackupDirectory = 1
+ Assignments = @(
+ [PSCustomObject]@{
+ AssignmentType = 'All Devices'
+ GroupId = $null
+ FilterId = $null
+ FilterType = 'None'
+ }
+ )
+ }
+ $lapsPolicies = [System.Collections.Generic.List[object]]::new()
+ $lapsPolicies.Add($policy)
+ $ctx = [PSCustomObject]@{
+ BitLockerPolicies = [System.Collections.Generic.List[object]]::new()
+ LapsPolicies = $lapsPolicies
+ BitLockerKeyDeviceIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
+ GroupDeviceAadIds = @{}
+ GroupUserUpns = @{}
+ GroupUserIds = @{}
+ Filters = @{}
+ EntraDeviceIdByName = @{}
+ LapsCredentialByDeviceId = @{}
+ LapsCredentialByDeviceName = @{}
+ }
+ # Use the REAL field names produced by Get-AllDeviceData so a typo'd
+ # resolver lookup (e.g. $d.OwnerType when the device exposes DeviceOwnership)
+ # is caught by this test instead of being silently hidden behind a synthetic
+ # field. See FINDINGS: "synthetic-fixture-hides-field-name-bug".
+ $d = [PSCustomObject]@{
+ DeviceName = 'TEST-DEVICE-1'
+ AzureAdDeviceId = '11111111-2222-3333-4444-555555555555'
+ Encrypted = $true
+ PrimaryUser = 'user@test.local'
+ Serialnumber = 'TEST-SN-1'
+ DeviceManufacturer = 'TestCorp'
+ DeviceModel = 'TestModel'
+ DeviceOwnership = 'company' # matches Get-AllDeviceData's output property
+ }
+ $rows = Resolve-IntuneLapsAnomalies -Devices @($d) -Context $ctx -TenantName 'TestTenant'
+ $rows[0]
+ }
+ $row | Should -Not -BeNullOrEmpty -Because 'a device covered by a LAPS policy with no credential must surface'
+ $actualColumns = @($row.PSObject.Properties.Name)
+ $expectedColumns = @('Customer','DeviceName','PrimaryUser','Serialnumber','OwnerType','AppliedPolicies','LastBackupDateTime','BackupAgeDays','Severity','Status')
+ $actualColumns | Should -Be $expectedColumns -Because 'LAPS anomaly rows must match the HTML template
order (Severity column comes before Status)'
+ $row.OwnerType | Should -Be 'company' -Because 'the LAPS resolver must read the device ownership from the same field Get-AllDeviceData emits (DeviceOwnership), otherwise the Ownership column will be blank on every real device'
+ }
+}
+
+Describe 'Connect-RKGraph default scopes match docs/PERMISSIONS.md' {
+ # Detects drift between Connect-RKGraph's hard-coded default -RequiredScopes
+ # and the documented permissions table. Either both are right, or both are wrong,
+ # but they must not silently diverge.
+
+ BeforeAll {
+ $modulePath = Split-Path (Get-RKSolutionsManifestPath) -Parent
+ $repoRoot = Split-Path $modulePath -Parent
+ $script:connectSource = Get-Content -Raw -Path (Join-Path $modulePath 'Public/Connect-RKGraph.ps1')
+ $script:permissionsDoc = Get-Content -Raw -Path (Join-Path $repoRoot 'docs/PERMISSIONS.md')
+ }
+
+ It 'Every scope mentioned in Connect-RKGraph appears in docs/PERMISSIONS.md' {
+ $scopeMatches = [regex]::Matches($script:connectSource, "'([A-Za-z0-9]+\.[A-Za-z]+(?:\.[A-Za-z]+)?(?:\.All)?)'")
+ $scopes = @($scopeMatches | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique | Where-Object { $_ -match '\.' -and $_ -notmatch '^Microsoft\.' })
+ $scopes.Count | Should -BeGreaterThan 0 -Because 'Connect-RKGraph should declare some default Graph scopes'
+ foreach ($scope in $scopes) {
+ $script:permissionsDoc | Should -Match ([regex]::Escape($scope)) -Because "scope '$scope' is granted by Connect-RKGraph but is not documented in docs/PERMISSIONS.md"
+ }
+ }
+}
diff --git a/docs/CMDLET-REFERENCE.md b/docs/CMDLET-REFERENCE.md
index d032f9d..df37187 100644
--- a/docs/CMDLET-REFERENCE.md
+++ b/docs/CMDLET-REFERENCE.md
@@ -54,21 +54,25 @@ Connect first with **Connect-RKGraph**; this cmdlet uses the existing connection
## Get-IntuneAnomaliesReport
-Generates Intune anomalies report.
+Generates an interactive HTML report covering Intune app failures, multi-user devices, missing Autopilot hashes, inactive devices, non-compliant devices, disabled primary users, BitLocker key escrow, Windows LAPS backup, and deprecated Settings Catalog settings.
-| Parameter | Description |
-| ------------ | ------------------------------ |
-| **SendEmail** | Send report by email. |
-| **Recipient** | Email recipient(s). |
-| **From** | From address. |
-| **ExportPath** | Output file path. |
-| **DebugMode** | Enable debug output. |
+| Parameter | Description |
+| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **SendEmail** | Send report by email. |
+| **Recipient** | Email recipient(s). |
+| **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. |
Connect first with **Connect-RKGraph**; this cmdlet uses the existing connection (no auth parameters).
-**Example:** `Get-IntuneAnomaliesReport`
+**Examples:**
+- `Get-IntuneAnomaliesReport` — generate the full report
+- `Get-IntuneAnomaliesReport -ShowExcludedDevices` — also list devices that admins explicitly excluded from BitLocker / LAPS policies
+- `Get-IntuneAnomaliesReport -SendEmail -Recipient 'admin@contoso.com' -From 'reports@contoso.com'` — generate and email
---
diff --git a/docs/PERMISSIONS.md b/docs/PERMISSIONS.md
index 8774412..ec7a251 100644
--- a/docs/PERMISSIONS.md
+++ b/docs/PERMISSIONS.md
@@ -11,8 +11,10 @@ Use these when registering an app in Azure AD / Entra ID or when calling `Connec
| Permission | Used by |
|------------|---------|
| **AuditLog.Read.All** | Get-EntraAdminRolesReport, Get-M365LicenseAssignmentReport |
+| **BitlockerKey.ReadBasic.All** | Get-IntuneAnomaliesReport |
| **CustomSecAttributeAssignment.Read.All** | Get-CustomSecurityAttributesReport |
| **CustomSecAttributeDefinition.Read.All** | Get-CustomSecurityAttributesReport |
+| **DeviceLocalCredential.ReadBasic.All** | Get-IntuneAnomaliesReport |
| **CloudLicensing.Read** | Get-M365LicenseAssignmentReport |
| **CloudPC.Read.All** | Get-IntuneEnrollmentFlowsReport, Get-IntuneAnomaliesReport |
| **Device.Read.All** | Get-IntuneEnrollmentFlowsReport |
@@ -59,6 +61,8 @@ Default `-RequiredScopes` is the **union** of all scopes below so one connection
- CloudPC.Read.All
- CustomSecAttributeAssignment.Read.All
- CustomSecAttributeDefinition.Read.All
+- BitlockerKey.ReadBasic.All
+- DeviceLocalCredential.ReadBasic.All
---
@@ -78,6 +82,7 @@ Default `-RequiredScopes` is the **union** of all scopes below so one connection
- User.Read
- User.Read.All
+- Group.Read.All
- DeviceManagementManagedDevices.Read.All
- DeviceManagementConfiguration.Read.All
- DeviceManagementServiceConfig.Read.All
@@ -85,6 +90,10 @@ Default `-RequiredScopes` is the **union** of all scopes below so one connection
- Directory.Read.All
- Mail.Send
- CloudPC.Read.All
+- BitlockerKey.ReadBasic.All
+- DeviceLocalCredential.ReadBasic.All
+
+The two new scopes drive the BitLocker key escrow and Windows LAPS backup anomaly checks. They are intentionally the **ReadBasic** variants: presence and metadata only, never the recovery password or local admin password itself.
---
diff --git a/module/Private/Connect-ToMgGraph.ps1 b/module/Private/Connect-ToMgGraph.ps1
index 79b26ed..0fbfc31 100644
--- a/module/Private/Connect-ToMgGraph.ps1
+++ b/module/Private/Connect-ToMgGraph.ps1
@@ -1,4 +1,113 @@
# Private: Connect to Microsoft Graph (Interactive, ClientSecret, Certificate, Identity, AccessToken)
+
+function Clear-RKMsalTokenCache {
+ <#
+ Wipes the persistent MSAL token cache used by Microsoft.Graph.Authentication so
+ the next Connect-MgGraph is forced into a fresh interactive sign-in. Disconnect-MgGraph
+ only clears in-process state; without this, MSAL silently reuses a refresh token
+ whose scope set predates any newly added module scopes.
+ #>
+ [CmdletBinding()]
+ param()
+ $cleared = 0
+ $filePaths = @(
+ "$HOME/.IdentityService/msal.cache"
+ "$HOME/.local/share/.IdentityService/msal.cache"
+ "$HOME/.config/.IdentityService/msal.cache"
+ )
+ if ($env:LOCALAPPDATA) { $filePaths += "$env:LOCALAPPDATA/.IdentityService/msal.cache" }
+ foreach ($p in $filePaths) {
+ if ($p -and (Test-Path $p)) {
+ try { Remove-Item $p -Force -ErrorAction Stop; $cleared++; Write-Verbose "Removed $p" } catch { Write-Verbose "Could not remove $p : $($_.Exception.Message)" }
+ }
+ }
+ if ($IsMacOS) {
+ # Mg SDK on macOS persists the MSAL cache to the login Keychain. Multiple item
+ # names are possible across SDK versions; loop each known service name until
+ # `security` reports nothing left to delete.
+ $services = @('Microsoft.Developer.IdentityService', 'com.microsoft.adalcache', 'com.microsoft.identitymodel.adalcache', 'MSALCache')
+ foreach ($svc in $services) {
+ do {
+ & security delete-generic-password -s $svc 2>$null | Out-Null
+ $deleted = ($LASTEXITCODE -eq 0)
+ if ($deleted) { $cleared++; Write-Verbose "Removed Keychain item service=$svc" }
+ } while ($deleted)
+ }
+ }
+ return $cleared
+}
+
+function Invoke-RKConsentGrantRepair {
+ <#
+ When Connect-MgGraph completes but the resulting token is missing scopes the
+ caller asked for, the most common cause is the Mg SDK reusing a cached refresh
+ token whose oauth2PermissionGrant predates the scope addition. If the current
+ session holds DelegatedPermissionGrant.ReadWrite.All this function PATCHes the
+ user's existing grant to add the missing scopes, wipes the MSAL token cache,
+ and triggers a fresh interactive sign-in. Returns $true on success, $false
+ when the gap could not be closed automatically.
+ #>
+ [CmdletBinding()]
+ param(
+ [Parameter(Mandatory)] [string[]] $MissingScopes,
+ [Parameter(Mandatory)] [string[]] $RequiredScopes,
+ [Parameter(Mandatory)] [PSCustomObject] $CurrentContext
+ )
+
+ if ('DelegatedPermissionGrant.ReadWrite.All' -notin $CurrentContext.Scopes) {
+ Write-Warning "Connected, but missing scopes after sign-in: $($MissingScopes -join ', ')"
+ Write-Warning "Auto-repair needs DelegatedPermissionGrant.ReadWrite.All (not present in your session). Options to fix:"
+ Write-Warning " 1) Have a Global Admin grant admin consent for those scopes on the 'Microsoft Graph Command Line Tools' enterprise app, OR"
+ Write-Warning " 2) Reconnect with Connect-MgGraph -Scopes DelegatedPermissionGrant.ReadWrite.All,$($MissingScopes -join ',') -UseDeviceCode"
+ return $false
+ }
+
+ Write-Host "Detected scope gap after sign-in; attempting auto-repair of consent grant..." -ForegroundColor Yellow
+
+ try {
+ $clientId = $CurrentContext.ClientId
+ $sp = (Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$clientId'&`$select=id,displayName").value | Select-Object -First 1
+ if (-not $sp) { Write-Warning "Could not locate service principal for clientId $clientId"; return $false }
+
+ $meId = (Invoke-MgGraphRequest -Method GET -Uri 'https://graph.microsoft.com/v1.0/me?$select=id').id
+ $grant = (Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants?`$filter=clientId eq '$($sp.id)' and principalId eq '$meId'").value | Select-Object -First 1
+ if (-not $grant) { Write-Warning "No existing consent grant found to patch."; return $false }
+
+ $current = ($grant.scope -split '\s+') | Where-Object { $_ }
+ $toAdd = $MissingScopes | Where-Object { $_ -notin $current }
+ if ($toAdd) {
+ $newScope = ($current + $toAdd) -join ' '
+ Invoke-MgGraphRequest -Method PATCH -Uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$($grant.id)" -Body @{ scope = $newScope } | Out-Null
+ Write-Host " Added to consent grant: $($toAdd -join ', ')" -ForegroundColor Green
+ }
+ }
+ catch {
+ Write-Warning "Consent grant patch failed: $($_.Exception.Message)"
+ return $false
+ }
+
+ Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
+ [void](Clear-RKMsalTokenCache)
+ Write-Host "Reconnecting (browser will open for fresh interactive sign-in)..." -ForegroundColor Yellow
+
+ try {
+ $p = @{ Scopes = $RequiredScopes; NoWelcome = $true }
+ if ($CurrentContext.TenantId) { $p.TenantId = $CurrentContext.TenantId }
+ if ($CurrentContext.ClientId) { $p.ClientId = $CurrentContext.ClientId }
+ Connect-MgGraph @p
+ $verify = Get-MgContext
+ $stillMissing = $RequiredScopes | Where-Object { $_ -notin $verify.Scopes }
+ if ($stillMissing) {
+ Write-Warning "Auto-repair completed, but scopes still missing: $($stillMissing -join ', '). Close this PowerShell session and try again from a fresh terminal."
+ return $false
+ }
+ return $true
+ } catch {
+ Write-Warning "Reconnect after consent patch failed: $($_.Exception.Message)"
+ return $false
+ }
+}
+
function Connect-ToMgGraph {
[CmdletBinding(DefaultParameterSetName = 'Interactive')]
param(
@@ -33,6 +142,9 @@ function Connect-ToMgGraph {
[Parameter(Mandatory = $true, ParameterSetName = 'AccessToken')]
[SecureString] $AccessToken,
+ [Parameter(Mandatory = $false)]
+ [switch] $SkipAutoRepair,
+
[Parameter(Mandatory = $false)]
[switch] $DebugMode
)
@@ -88,7 +200,20 @@ function Connect-ToMgGraph {
}
}
$newContext = Get-MgContext
- if ($newContext) { return $newContext }
+ if ($newContext) {
+ # Detect the MSAL silent-reuse trap: Connect-MgGraph "succeeded" but the
+ # resulting token is missing scopes we explicitly asked for. Auto-repair
+ # by PATCHing the consent grant + wiping the token cache, unless opted out.
+ if ($AuthMethod -eq 'Interactive' -and -not $SkipAutoRepair) {
+ $stillMissing = $RequiredScopes | Where-Object { $_ -notin $newContext.Scopes }
+ if ($stillMissing) {
+ if (Invoke-RKConsentGrantRepair -MissingScopes $stillMissing -RequiredScopes $RequiredScopes -CurrentContext $newContext) {
+ $newContext = Get-MgContext
+ }
+ }
+ }
+ return $newContext
+ }
throw 'Connection attempt completed but unable to confirm connection'
} catch {
Write-Error "Error connecting to Microsoft Graph: $_"
diff --git a/module/Private/EntraAdminRoles.ps1 b/module/Private/EntraAdminRoles.ps1
index 609bf96..d3ba646 100644
--- a/module/Private/EntraAdminRoles.ps1
+++ b/module/Private/EntraAdminRoles.ps1
@@ -1174,79 +1174,6 @@ Function Get-PIMAuditLogs {
return $results
}
-
-function Get-GroupActivationDetails {
- param (
- [Parameter(Mandatory = $true)]
- [AllowEmptyCollection()]
- [array]$ActivatedMembers,
- [Parameter(Mandatory = $true)]
- [array]$PIMAuditLogs,
- [Parameter(Mandatory = $true)]
- [string]$RoleName
- )
-
- $enrichedActivations = @()
-
- # Return empty array if no activated members (PowerShell 5 compatible)
- if (-not $ActivatedMembers -or @($ActivatedMembers).Count -eq 0) {
- return $enrichedActivations
- }
-
- foreach ($member in $ActivatedMembers) {
- # Find corresponding PIM audit logs for this member and role
- $memberAuditLogs = $PIMAuditLogs | Where-Object {
- ($_.Target -eq $member.UserPrincipalName -or $_.TargetUserId -eq $member.UserId) -and
- $_.Role -eq $RoleName -and
- $_.IsGroupBasedActivation -eq $true -and
- $_.Result -eq "Success" -and
- ($_.Operation -like "*Activate*" -or $_.OperationType -eq "Assign")
- }
-
- # Get the most recent activation for this member
- $recentActivation = $memberAuditLogs | Sort-Object DateTime -Descending | Select-Object -First 1
-
- if ($recentActivation) {
- $enrichedActivations += [PSCustomObject]@{
- UserPrincipalName = $member.UserPrincipalName
- DisplayName = $member.DisplayName
- UserId = $member.UserId
- ActivationTime = $member.ActivationTime
- StartTime = $member.StartTime
- EndTime = $member.EndTime
- AssignmentState = $member.AssignmentState
- MemberType = $member.MemberType
- # Enhanced with audit log data
- AuditLogDateTime = $recentActivation.DateTime
- ActivatedBy = $recentActivation.InitiatedBy
- Justification = $recentActivation.Justification
- Duration = $recentActivation.Duration
- RequestID = $recentActivation.RequestID
- }
- } else {
- # Include member even without audit log correlation
- $enrichedActivations += [PSCustomObject]@{
- UserPrincipalName = $member.UserPrincipalName
- DisplayName = $member.DisplayName
- UserId = $member.UserId
- ActivationTime = $member.ActivationTime
- StartTime = $member.StartTime
- EndTime = $member.EndTime
- AssignmentState = $member.AssignmentState
- MemberType = $member.MemberType
- # No audit log data found
- AuditLogDateTime = "N/A"
- ActivatedBy = "N/A"
- Justification = "N/A"
- Duration = "N/A"
- RequestID = "N/A"
- }
- }
- }
-
- return $enrichedActivations
-}
-
function Invoke-EntraAdminRolesReportCore {
param(
[Parameter(Mandatory=$false)] [switch] $SendEmail,
diff --git a/module/Private/Get-RKIntuneSettingsCatalog.ps1 b/module/Private/Get-RKIntuneSettingsCatalog.ps1
new file mode 100644
index 0000000..1aeea15
--- /dev/null
+++ b/module/Private/Get-RKIntuneSettingsCatalog.ps1
@@ -0,0 +1,95 @@
+# Private: load the Intune Settings Catalog data into an in-memory lookup keyed by
+# settingDefinitionId. Used by IntuneAnomalies' deprecation walker to resolve a
+# settingDefinitionId to its Microsoft DisplayName (Microsoft flags retired
+# settings in the display name itself, e.g. "...(deprecated)").
+#
+# Source: https://github.com/royklo/IntuneSettingsCatalogData
+# Cache : $LOCALAPPDATA/RKSolutions/settings-catalog.json on Windows,
+# ~/.rksolutions/settings-catalog.json elsewhere
+# TTL : 24 hours (based on file LastWriteTime; no separate metadata file)
+#
+# On any failure (offline, parse error, missing release) the function returns an
+# empty hashtable. Callers must handle the empty case gracefully - the
+# deprecation regex check still works against raw setting name/value strings, so
+# detection only degrades to "less complete" not "broken".
+
+function Get-RKIntuneSettingsCatalog {
+ [CmdletBinding()]
+ [OutputType([hashtable])]
+ param(
+ [Parameter(Mandatory = $false)]
+ [switch] $Force
+ )
+
+ if (-not $Force -and $null -ne $script:RKIntuneSettingsCatalog) {
+ return $script:RKIntuneSettingsCatalog
+ }
+
+ # Per-OS cache directory. Falls back to $HOME on platforms without LOCALAPPDATA.
+ $cacheDir = if ($IsWindows -and $env:LOCALAPPDATA) {
+ Join-Path $env:LOCALAPPDATA 'RKSolutions'
+ } else {
+ Join-Path $HOME '.rksolutions'
+ }
+ $cacheFile = Join-Path $cacheDir 'settings-catalog.json'
+ $ttl = New-TimeSpan -Hours 24
+ $sourceUrl = 'https://github.com/royklo/IntuneSettingsCatalogData/releases/latest/download/settings.json'
+
+ $needDownload = $true
+ if (Test-Path -LiteralPath $cacheFile) {
+ $age = (Get-Date) - (Get-Item -LiteralPath $cacheFile).LastWriteTime
+ if ($age -lt $ttl) {
+ $needDownload = $false
+ Write-Verbose ("Settings catalog cache is fresh (age {0:N1}h)" -f $age.TotalHours)
+ }
+ }
+
+ if ($needDownload) {
+ if (-not (Test-Path -LiteralPath $cacheDir)) {
+ New-Item -ItemType Directory -Path $cacheDir -Force | Out-Null
+ }
+ Write-Host ' Refreshing Intune Settings Catalog data from GitHub...' -ForegroundColor Cyan
+ $savedProgress = $ProgressPreference
+ try {
+ $ProgressPreference = 'SilentlyContinue'
+ $tmpFile = "$cacheFile.tmp"
+ Invoke-WebRequest -Uri $sourceUrl -OutFile $tmpFile -UseBasicParsing -ErrorAction Stop
+ Move-Item -Path $tmpFile -Destination $cacheFile -Force
+ }
+ catch {
+ Remove-Item -Path "$cacheFile.tmp" -Force -ErrorAction SilentlyContinue
+ if (Test-Path -LiteralPath $cacheFile) {
+ Write-Warning "Could not refresh Intune Settings Catalog data ($($_.Exception.Message)); using stale cache."
+ } else {
+ Write-Warning "Could not download Intune Settings Catalog data ($($_.Exception.Message)). Deprecation detection will fall back to name/value regex only."
+ $script:RKIntuneSettingsCatalog = @{}
+ return $script:RKIntuneSettingsCatalog
+ }
+ }
+ finally {
+ $ProgressPreference = $savedProgress
+ }
+ }
+
+ try {
+ $raw = Get-Content -Path $cacheFile -Raw -Encoding UTF8
+ $entries = $raw | ConvertFrom-Json -AsHashtable -Depth 100
+ $lookup = @{}
+ foreach ($e in $entries) {
+ $id = $e['id']
+ if ([string]::IsNullOrEmpty($id)) { continue }
+ $lookup[$id] = @{
+ DisplayName = $e['displayName']
+ Description = $e['description']
+ }
+ }
+ $script:RKIntuneSettingsCatalog = $lookup
+ Write-Host " Loaded $($lookup.Count) catalog entries." -ForegroundColor Gray
+ }
+ catch {
+ Write-Warning "Failed to parse Intune Settings Catalog data: $($_.Exception.Message)"
+ $script:RKIntuneSettingsCatalog = @{}
+ }
+
+ return $script:RKIntuneSettingsCatalog
+}
diff --git a/module/Private/Get-RKSolutionsReportTemplate.ps1 b/module/Private/Get-RKSolutionsReportTemplate.ps1
index d5105db..0218f60 100644
--- a/module/Private/Get-RKSolutionsReportTemplate.ps1
+++ b/module/Private/Get-RKSolutionsReportTemplate.ps1
@@ -186,6 +186,16 @@ $StatsCardsHtml
--tile-slate-text: #cbd5e1;
--tile-slate-eyebrow: #94a3b8;
--tile-slate-caption: #64748b;
+ --tile-indigo-bg: #312e81;
+ --tile-indigo-border: #3730a333;
+ --tile-indigo-text: #a5b4fc;
+ --tile-indigo-eyebrow: #818cf8;
+ --tile-indigo-caption: #4338ca;
+ --tile-pink-bg: #831843;
+ --tile-pink-border: #9f123933;
+ --tile-pink-text: #f9a8d4;
+ --tile-pink-eyebrow: #f472b6;
+ --tile-pink-caption: #be185d;
--toggle-bg: #404040;
--input-bg: #141414;
--input-border: #262626;
@@ -390,6 +400,12 @@ input:checked + .rk-theme-toggle-slider:before { transform: translateX(18px); }
.rk-stat-tile.t-slate { background: linear-gradient(135deg, #475569, #64748b); color: #fff; }
.rk-stat-tile.t-slate .rk-stat-eyebrow { opacity: 0.92; }
.rk-stat-tile.t-slate .rk-stat-caption { opacity: 0.85; }
+.rk-stat-tile.t-indigo { background: linear-gradient(135deg, #4f46e5, #6366f1); color: #fff; }
+.rk-stat-tile.t-indigo .rk-stat-eyebrow { opacity: 0.92; }
+.rk-stat-tile.t-indigo .rk-stat-caption { opacity: 0.85; }
+.rk-stat-tile.t-pink { background: linear-gradient(135deg, #db2777, #ec4899); color: #fff; }
+.rk-stat-tile.t-pink .rk-stat-eyebrow { opacity: 0.92; }
+.rk-stat-tile.t-pink .rk-stat-caption { opacity: 0.85; }
/* Dark mode tiles: gradient tinted bg, bright colored text */
[data-theme="dark"] .rk-stat-tile.t-rust {
@@ -432,6 +448,16 @@ input:checked + .rk-theme-toggle-slider:before { transform: translateX(18px); }
}
[data-theme="dark"] .rk-stat-tile.t-slate .rk-stat-eyebrow { color: var(--tile-slate-eyebrow); opacity: 1; }
[data-theme="dark"] .rk-stat-tile.t-slate .rk-stat-caption { color: var(--tile-slate-caption); opacity: 1; }
+[data-theme="dark"] .rk-stat-tile.t-indigo {
+ background: linear-gradient(135deg, var(--tile-indigo-bg), #251c54); border: 1px solid var(--tile-indigo-border); color: var(--tile-indigo-text);
+}
+[data-theme="dark"] .rk-stat-tile.t-indigo .rk-stat-eyebrow { color: var(--tile-indigo-eyebrow); opacity: 1; }
+[data-theme="dark"] .rk-stat-tile.t-indigo .rk-stat-caption { color: var(--tile-indigo-caption); opacity: 1; }
+[data-theme="dark"] .rk-stat-tile.t-pink {
+ background: linear-gradient(135deg, var(--tile-pink-bg), #4b1530); border: 1px solid var(--tile-pink-border); color: var(--tile-pink-text);
+}
+[data-theme="dark"] .rk-stat-tile.t-pink .rk-stat-eyebrow { color: var(--tile-pink-eyebrow); opacity: 1; }
+[data-theme="dark"] .rk-stat-tile.t-pink .rk-stat-caption { color: var(--tile-pink-caption); opacity: 1; }
/* --- Filter Bar --- */
.rk-filter-bar {
@@ -662,6 +688,29 @@ input:checked + .rk-toggle-slider:before { transform: translateX(20px); }
.dataTables_wrapper .dataTables_info,
.dataTables_wrapper .dataTables_paginate { color: var(--text-body) !important; }
+/* External per-table search input lives inside each rk-filter-bar. Bootstrap's
+ .form-control provides the base contrast / border styling - we only nudge
+ the typography to match the rest of the filter row and push it to the left
+ so the dropdowns stay grouped on the right. */
+.rk-search-input.form-control {
+ font-family: 'Geist Mono', ui-monospace, monospace;
+ font-size: 0.78rem;
+ margin-right: auto;
+}
+.rk-search-input.form-control:focus {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(234, 88, 12, 0.18);
+}
+
+.rk-dt-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 12px;
+ padding: 14px 0 0 0;
+}
+
.dataTables_wrapper .dataTables_length select,
.dataTables_wrapper .dataTables_filter input {
border: 1px solid var(--input-border);
@@ -869,7 +918,11 @@ $CustomCss
// DataTable helper
function initRKTable(selector, extraOptions) {
var defaults = {
- dom: 'frtip',
+ // No 'f' here - the search input is injected manually into each panel's
+ // .rk-filter-bar by linkRKSearchInputs() so we don't have to fight DataTables
+ // + Bootstrap5 styling for the built-in filter element. 't'able, 'i'nfo,
+ // 'p'agination are still rendered by DataTables.
+ dom: 'rt<"rk-dt-footer"ip>',
buttons: [
{
extend: 'collection',
@@ -919,7 +972,23 @@ function initRKTable(selector, extraOptions) {
}
};
if (extraOptions) { `$.extend(true, defaults, extraOptions); }
- return `$(selector).DataTable(defaults);
+ var dt = `$(selector).DataTable(defaults);
+
+ // Inject a per-table search input into the containing panel's filter bar.
+ // We render this ourselves rather than relying on DataTables's built-in 'f'
+ // element because the latter loses contrast inside the dark card-body in
+ // some browsers and gets visually lost above the table.
+ var panel = `$(selector).closest('.rk-panel');
+ var filterBar = panel.find('.rk-filter-bar').first();
+ if (filterBar.length && !filterBar.find('.rk-search-input').length) {
+ // Use Bootstrap's form-control alongside our own class so the input picks up
+ // the same border/contrast styling as the existing .form-select dropdowns,
+ // which the rest of the report already relies on for visibility.
+ var search = `$('');
+ search.on('input', function() { dt.search(`$(this).val()).draw(); });
+ filterBar.prepend(search);
+ }
+ return dt;
}
diff --git a/module/Private/IntuneAnomalies.ps1 b/module/Private/IntuneAnomalies.ps1
index d266a1e..e654bcc 100644
--- a/module/Private/IntuneAnomalies.ps1
+++ b/module/Private/IntuneAnomalies.ps1
@@ -10,8 +10,6 @@ function New-IntuneAnomaliesHTMLReport {
[Parameter(Mandatory = $false)]
[array]$Report_DevicesWithMultipleUsers,
[Parameter(Mandatory = $false)]
- [array]$Report_NotEncryptedDevices,
- [Parameter(Mandatory = $false)]
[array]$Report_DevicesWithoutAutopilotHash,
[Parameter(Mandatory = $false)]
[array]$Report_InactiveDevices,
@@ -22,6 +20,12 @@ function New-IntuneAnomaliesHTMLReport {
[Parameter(Mandatory = $false)]
[array]$Report_DisabledPrimaryUsers,
[Parameter(Mandatory = $false)]
+ [array]$Report_BitLockerStatus,
+ [Parameter(Mandatory = $false)]
+ [array]$Report_LapsStatus,
+ [Parameter(Mandatory = $false)]
+ [array]$Report_DeprecatedSettings,
+ [Parameter(Mandatory = $false)]
[string]$ExportPath
)
@@ -34,12 +38,15 @@ function New-IntuneAnomaliesHTMLReport {
# Calculate counts for dashboard statistics
$Report_ApplicationFailureReport_Count = $Report_ApplicationFailureReport | Measure-Object | Select-Object -ExpandProperty Count
$Report_DevicesWithMultipleUsers_Count = $Report_DevicesWithMultipleUsers | Measure-Object | Select-Object -ExpandProperty Count
- $Report_NotEncryptedDevices_Count = $Report_NotEncryptedDevices | Measure-Object | Select-Object -ExpandProperty Count
$Report_DevicesWithoutAutopilotHash_Count = $Report_DevicesWithoutAutopilotHash | Measure-Object | Select-Object -ExpandProperty Count
$Report_InactiveDevices_Count = $Report_InactiveDevices | Measure-Object | Select-Object -ExpandProperty Count
$Report_NoncompliantDevices_Count = ($Report_NoncompliantDevices | Select-Object -Property DeviceName -Unique | Measure-Object).Count
$Report_OperatingSystemEditionOverview_Count = $Report_OperatingSystemEditionOverview | Measure-Object | Select-Object -ExpandProperty Count
$Report_DisabledPrimaryUsers_Count = $Report_DisabledPrimaryUsers | Measure-Object | Select-Object -ExpandProperty Count
+ $Report_BitLockerStatus_Count = $Report_BitLockerStatus | Measure-Object | Select-Object -ExpandProperty Count
+ $Report_LapsStatus_Count = $Report_LapsStatus | Measure-Object | Select-Object -ExpandProperty Count
+ $Report_DeprecatedSettings_Count = $Report_DeprecatedSettings | Measure-Object | Select-Object -ExpandProperty Count
+ $Report_DeprecatedPolicies_Count = ($Report_DeprecatedSettings | Select-Object -ExpandProperty PolicyId -Unique | Measure-Object).Count
# Get the current date and time for the report header
$CurrentDate = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
@@ -74,21 +81,6 @@ function New-IntuneAnomaliesHTMLReport {
"@
}
- # Generate table rows for not encrypted devices
- $notEncryptedRows = ""
- foreach ($item in $Report_NotEncryptedDevices) {
- $notEncryptedRows += @"
-
+"@
+ }
+
+ # Generate table rows for deprecated Settings Catalog settings.
+ # Customer / ConfiguredValue / DetectionSource intentionally omitted - they're
+ # either constant (Customer) or redundant with the Setting Definition ID for
+ # the common choice-setting case where Value == DefId + "_". The full
+ # record still carries those fields for downstream consumers.
+ $deprecatedSettingsRows = ""
+ foreach ($item in $Report_DeprecatedSettings) {
+ $deprecatedSettingsRows += @"
+