From 217f1cd39980f6cd83c8c5fdfa4e01e4b433a036 Mon Sep 17 00:00:00 2001 From: Roy Klooster Date: Wed, 17 Jun 2026 20:21:26 +0200 Subject: [PATCH 01/14] feat(intune-anomalies): batch compliance fetches, BitLocker/LAPS escrow + deprecated settings, consent repair - Add Invoke-RKGraphBatch private helper (Graph $batch wrapper with chunking, 429/5xx retry, id correlation) - Refactor Get-AllDeviceData compliance fetch into two batched prefetch passes - Add BitLocker key escrow + Windows LAPS backup anomaly detection (assignment-aware, filter-aware, exclude-group-aware) - Add Deprecated Settings detection with cached Get-RKIntuneSettingsCatalog helper - Fold "Not Encrypted" tab into BitLocker tab (unencrypted devices surface there) - Add Connect-RKGraph auto-repair flow for missing scopes (Invoke-RKConsentGrantRepair + Clear-RKMsalTokenCache) - Inject template-wide search bar into .rk-filter-bar via initRKTable - Add verbose timing instrumentation across Get-IntuneAnomaliesReport and Get-AllDeviceData - Fix broken JS in rendered template (missing backtick escape in select.append here-string) Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 15 + docs/PERMISSIONS.md | 9 + module/Private/Connect-ToMgGraph.ps1 | 127 +- .../Private/Get-RKIntuneSettingsCatalog.ps1 | 95 ++ .../Private/Get-RKSolutionsReportTemplate.ps1 | 47 +- module/Private/IntuneAnomalies.ps1 | 1323 +++++++++++++++-- module/Private/Invoke-RKGraphBatch.ps1 | 153 ++ module/Public/Connect-RKGraph.ps1 | 13 +- module/Public/Get-IntuneAnomaliesReport.ps1 | 38 +- module/RKSolutions.psm1 | 8 +- 10 files changed, 1669 insertions(+), 159 deletions(-) create mode 100644 module/Private/Get-RKIntuneSettingsCatalog.ps1 create mode 100644 module/Private/Invoke-RKGraphBatch.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 8687e6c..c18d249 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ 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.). +## [Unreleased] + +### Features + +- Intune Anomalies Report gains two new checks: **BitLocker key escrow** and **Windows LAPS backup**. Each check resolves Intune policy assignments end-to-end (Settings Catalog by `settingDefinitionId`, legacy `windows10EndpointProtectionConfiguration`, Endpoint Security intents) and honours include / exclude groups, All Users / All Devices targets, and assignment filters - so the report flags both "no policy applied" and "policy applied but no key / no backup in Entra". +- A single bulk pass (`Get-BitLockerLapsAssignmentContext`) collects policies + assignments + filters + transitive group members + `informationProtection/bitlocker/recoveryKeys` + `directory/deviceLocalCredentials`; per-device evaluators (`Resolve-IntuneBitLockerAnomalies`, `Resolve-IntuneLapsAnomalies`) then emit severity-tagged anomaly rows. +- Two new dashboard tiles, tabs, and DataTables panels in the Intune Anomalies HTML report. + +### Maintenance + +- Expose `AzureAdDeviceId` on the device records produced by `Get-AllDeviceData` (required to join managed devices with Entra recovery keys and LAPS local credentials). +- Add `BitlockerKey.ReadBasic.All` and `DeviceLocalCredential.ReadBasic.All` to the default Connect-RKGraph scopes and to `docs/PERMISSIONS.md`. ReadBasic variants only - the report never reads recovery passwords or local admin passwords. + +--- + ## [1.1.0] ### Features 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/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..946a7d5 100644 --- a/module/Private/Get-RKSolutionsReportTemplate.ps1 +++ b/module/Private/Get-RKSolutionsReportTemplate.ps1 @@ -662,6 +662,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 +892,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 +946,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..28f1d70 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 += @" - - $([System.Net.WebUtility]::HtmlEncode($item.Customer)) - $([System.Net.WebUtility]::HtmlEncode($item.DeviceName)) - $([System.Net.WebUtility]::HtmlEncode($item.PrimaryUser)) - $([System.Net.WebUtility]::HtmlEncode($item.Serialnumber)) - $([System.Net.WebUtility]::HtmlEncode($item.DeviceManufacturer)) - $([System.Net.WebUtility]::HtmlEncode($item.DeviceModel)) - -"@ - } - # Generate table rows for Non-company owned devices $noAutopilotHashRows = "" foreach ($item in $Report_DevicesWithoutAutopilotHash) { @@ -154,6 +146,68 @@ function New-IntuneAnomaliesHTMLReport { "@ } + # Generate table rows for BitLocker key escrow anomalies + $bitLockerStatusRows = "" + foreach ($item in $Report_BitLockerStatus) { + $sevClass = switch ($item.Severity) { 'Critical' { 'rk-badge-error' } 'Warning' { 'rk-badge-warn' } default { 'rk-badge' } } + $bitLockerStatusRows += @" + + $([System.Net.WebUtility]::HtmlEncode($item.Customer)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceName)) + $([System.Net.WebUtility]::HtmlEncode($item.PrimaryUser)) + $([System.Net.WebUtility]::HtmlEncode($item.Serialnumber)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceManufacturer)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceModel)) + $([System.Net.WebUtility]::HtmlEncode($item.IsEncrypted)) + $([System.Net.WebUtility]::HtmlEncode($item.PolicyAssigned)) + $([System.Net.WebUtility]::HtmlEncode($item.AppliedPolicies)) + $([System.Net.WebUtility]::HtmlEncode($item.KeyEscrowed)) + $([System.Net.WebUtility]::HtmlEncode($item.Severity)) + $([System.Net.WebUtility]::HtmlEncode($item.Status)) + +"@ + } + + # Generate table rows for Windows LAPS backup anomalies + $lapsStatusRows = "" + foreach ($item in $Report_LapsStatus) { + $sevClass = switch ($item.Severity) { 'Critical' { 'rk-badge-error' } 'Warning' { 'rk-badge-warn' } default { 'rk-badge' } } + $lapsStatusRows += @" + + $([System.Net.WebUtility]::HtmlEncode($item.Customer)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceName)) + $([System.Net.WebUtility]::HtmlEncode($item.PrimaryUser)) + $([System.Net.WebUtility]::HtmlEncode($item.Serialnumber)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceManufacturer)) + $([System.Net.WebUtility]::HtmlEncode($item.DeviceModel)) + $([System.Net.WebUtility]::HtmlEncode($item.OwnerType)) + $([System.Net.WebUtility]::HtmlEncode($item.PolicyAssigned)) + $([System.Net.WebUtility]::HtmlEncode($item.AppliedPolicies)) + $([System.Net.WebUtility]::HtmlEncode($item.LastBackupDateTime)) + $([System.Net.WebUtility]::HtmlEncode($item.BackupAgeDays)) + $([System.Net.WebUtility]::HtmlEncode($item.Severity)) + $([System.Net.WebUtility]::HtmlEncode($item.Status)) + +"@ + } + + # 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 += @" + + $([System.Net.WebUtility]::HtmlEncode($item.PolicyName)) + $([System.Net.WebUtility]::HtmlEncode($item.Platform)) + $([System.Net.WebUtility]::HtmlEncode($item.SettingDisplayName)) + $([System.Net.WebUtility]::HtmlEncode($item.SettingDefinitionId)) + +"@ + } + # Generate table rows for disabled primary users $disabledPrimaryUsersRows = "" foreach ($item in $Report_DisabledPrimaryUsers) { @@ -181,11 +235,6 @@ function New-IntuneAnomaliesHTMLReport {
$Report_DevicesWithMultipleUsers_Count
Non-shared devices
-
-
NOT ENCRYPTED
-
$Report_NotEncryptedDevices_Count
-
Unencrypted devices
-
NO AUTOPILOT HASH
$Report_DevicesWithoutAutopilotHash_Count
@@ -211,6 +260,21 @@ function New-IntuneAnomaliesHTMLReport {
$Report_DisabledPrimaryUsers_Count
Disabled primary users
+
+
BITLOCKER
+
$Report_BitLockerStatus_Count
+
Encryption / escrow gaps
+
+
+
LAPS BACKUP
+
$Report_LapsStatus_Count
+
LAPS coverage gaps
+
+
+
DEPRECATED SETTINGS
+
$Report_DeprecatedSettings_Count
+
Across $Report_DeprecatedPolicies_Count polic(ies)
+
"@ # Build body content HTML (tabs + panels + filter containers + tables + script) @@ -219,12 +283,14 @@ function New-IntuneAnomaliesHTMLReport {
- + + +
@@ -339,58 +405,6 @@ function New-IntuneAnomaliesHTMLReport { - -
-
- Filters: - - - - - - -
-
-
- Not Encrypted Devices -
- - Show all -
-
-
- - - - - - - - - - - - - $notEncryptedRows - -
CustomerDevice NamePrimary UserSerial NumberManufacturerModel
-
-
-
-
@@ -662,17 +676,180 @@ function New-IntuneAnomaliesHTMLReport {
+ +
+
+ Filters: + + + + + + + +
+
+
+ BitLocker Anomalies +
+ + Show all +
+
+
+ + + + + + + + + + + + + + + + + + + $bitLockerStatusRows + +
CustomerDevice NamePrimary UserSerial NumberManufacturerModelEncryptedPolicy AssignedApplied PoliciesKey EscrowedSeverityStatus
+
+
+
+ + +
+
+ Filters: + + + + + + + +
+
+
+ Windows LAPS Backup Anomalies +
+ + Show all +
+
+
+ + + + + + + + + + + + + + + + + + + + $lapsStatusRows + +
CustomerDevice NamePrimary UserSerial NumberManufacturerModelOwnershipPolicy AssignedApplied PoliciesLast BackupBackup Age (days)SeverityStatus
+
+
+
+ + +
+
+ Filters: + + +
+
+
+ Deprecated Intune Settings +
+ + Show all +
+
+
+ + + + + + + + + + + $deprecatedSettingsRows + +
Policy NamePlatformSetting Display NameSetting Definition ID
+
+
+
+