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 += @" - - $([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,62 @@ 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.IsEncrypted)) + $([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.OwnerType)) + $([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 +229,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 +254,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 +277,14 @@ function New-IntuneAnomaliesHTMLReport {
- + + +
@@ -339,58 +399,6 @@ function New-IntuneAnomaliesHTMLReport { - -
-
- Filters: - - - - - - -
-
-
- Not Encrypted Devices -
- - Show all -
-
-
- - - - - - - - - - - - - $notEncryptedRows - -
CustomerDevice NamePrimary UserSerial NumberManufacturerModel
-
-
-
-
@@ -662,17 +670,168 @@ function New-IntuneAnomaliesHTMLReport {
+ +
+
+ Filters: + + + + + + +
+
+
+ BitLocker Anomalies +
+ + Show all +
+
+
+ + + + + + + + + + + + + + + + $bitLockerStatusRows + +
CustomerDevice NamePrimary UserSerial NumberEncryptedApplied PoliciesKey EscrowedSeverityStatus
+
+
+
+ + +
+
+ Filters: + + + + + + +
+
+
+ Windows LAPS Backup Anomalies +
+ + Show all +
+
+
+ + + + + + + + + + + + + + + + + $lapsStatusRows + +
CustomerDevice NamePrimary UserSerial NumberOwnershipApplied PoliciesLast BackupBackup Age (days)SeverityStatus
+
+
+
+ + +
+
+ Filters: + + +
+
+
+ Deprecated Intune Settings +
+ + Show all +
+
+
+ + + + + + + + + + + $deprecatedSettingsRows + +
Policy NamePlatformSetting Display NameSetting Definition ID
+
+
+
+