Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
167 changes: 167 additions & 0 deletions Tests/Consistency.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 <td> 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 <td> 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 <td> 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"
}
}
}
22 changes: 13 additions & 9 deletions docs/CMDLET-REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand Down
9 changes: 9 additions & 0 deletions docs/PERMISSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

---

Expand All @@ -78,13 +82,18 @@ 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
- DeviceManagementApps.Read.All
- 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.

---

Expand Down
Loading