Core/IntuneAnomalyResolvers.ps1
|
function ConvertTo-ComplianceSettingFriendlyName { param([string] $SettingName) if ($SettingName -match '^(Windows10CompliancePolicy|DefaultDeviceCompliancePolicy)\.(.+)$' -and $script:ComplianceSettingFriendlyNames.ContainsKey($Matches[2])) { return $script:ComplianceSettingFriendlyNames[$Matches[2]] } return $SettingName } function Resolve-DeviceAzureAdDeviceId { <# Return the Azure AD device id to use for joining with BitLocker recovery keys and LAPS credentials. Prefer the value already on the device record; fall back to the deviceName -> deviceId map populated by Get-BitLockerLapsAssignmentContext. #> param([Parameter(Mandatory)] [PSCustomObject] $Device, [Parameter(Mandatory)] [PSCustomObject] $Context) if ($Device.AzureAdDeviceId) { return [string]$Device.AzureAdDeviceId } if ($Device.DeviceName) { $key = ([string]$Device.DeviceName).ToLowerInvariant() if ($Context.EntraDeviceIdByName.ContainsKey($key)) { return $Context.EntraDeviceIdByName[$key] } } return $null } function ConvertTo-FilterDeviceProperties { <# Map a Get-AllDeviceData record onto the property names that Test-IntuneFilter recognises (deviceName, operatingSystem, osVersion, manufacturer, model, isEncrypted, ownerType, serialNumber, enrollmentProfileName, complianceState). #> param([Parameter(Mandatory)] [PSCustomObject] $Device) [PSCustomObject]@{ DeviceName = $Device.DeviceName OperatingSystem = $Device.Operatingsystem OSVersion = $Device.OperatingSystemVersion Manufacturer = $Device.DeviceManufacturer Model = $Device.DeviceModel IsEncrypted = $Device.Encrypted OwnerType = $Device.DeviceOwnership SerialNumber = $Device.Serialnumber EnrollmentProfileName = $Device.EnrollmentProfile ComplianceState = $Device.ComplianceStatus ProcessorArchitecture = $Device.ProcessorArchitecture DeviceType = 'desktop' UserPrincipalName = $Device.PrimaryUser AzureAdDeviceId = $Device.AzureAdDeviceId } } function Test-AssignmentAppliesToManagedDevice { <# Evaluates a single Get-DetailedPolicyAssignments row against a managed device record using the pre-resolved group membership sets in the context. Returns @{ Applies = $bool; FilterDecision = '...' }. Assignment filters are honored via Test-IntuneFilter (provided by Core/IntunePolicyAssignment.ps1). #> param( [Parameter(Mandatory)] [PSCustomObject] $Assignment, [Parameter(Mandatory)] [PSCustomObject] $Device, [Parameter(Mandatory)] [PSCustomObject] $Context ) $aadDeviceId = if ($Device.AzureAdDeviceId) { [string]$Device.AzureAdDeviceId } else { '' } $primaryUpn = if ($Device.PrimaryUser) { ([string]$Device.PrimaryUser).ToLowerInvariant() } else { '' } $baseApplies = $false switch ($Assignment.AssignmentType) { 'All Devices' { $baseApplies = $true } 'All Users' { $baseApplies = -not [string]::IsNullOrEmpty($primaryUpn) } 'Group (Include)' { if ($Assignment.GroupId) { $gid = [string]$Assignment.GroupId $inDeviceGroup = $false; $inUserGroup = $false if ($Context.GroupDeviceAadIds.ContainsKey($gid) -and $aadDeviceId) { $inDeviceGroup = $Context.GroupDeviceAadIds[$gid].Contains($aadDeviceId) } if ($Context.GroupUserUpns.ContainsKey($gid) -and $primaryUpn) { $inUserGroup = $Context.GroupUserUpns[$gid].Contains($primaryUpn) } $baseApplies = $inDeviceGroup -or $inUserGroup } } 'Group (Exclude)' { $baseApplies = $false } # Handled explicitly by caller as an exclusion signal. default { $baseApplies = $false } } if (-not $baseApplies) { return [PSCustomObject]@{ Applies = $false; FilterDecision = 'N/A' } } # Assignment filter handling. Filter rules use property names like deviceName, # operatingSystem, manufacturer, model, osVersion - all surfaced by Test-IntuneFilter. $filterDecision = 'N/A' $hasFilter = $Assignment.FilterId -and $Assignment.FilterId -ne '00000000-0000-0000-0000-000000000000' ` -and $Assignment.FilterType -and $Assignment.FilterType -ne 'None' -and $Assignment.FilterType -ne 'none' if ($hasFilter) { $filter = $Context.Filters[$Assignment.FilterId] if ($filter -and $filter.rule) { $filterDeviceProps = ConvertTo-FilterDeviceProperties -Device $Device $matched = Test-IntuneFilter -FilterRule $filter.rule -DeviceProperties $filterDeviceProps $filterDecision = if ($matched) { 'Matched' } else { 'NotMatched' } $ft = ([string]$Assignment.FilterType).ToLowerInvariant() if ($ft -eq 'include') { if (-not $matched) { return [PSCustomObject]@{ Applies = $false; FilterDecision = $filterDecision } } } elseif ($ft -eq 'exclude') { if ($matched) { return [PSCustomObject]@{ Applies = $false; FilterDecision = $filterDecision } } } } else { $filterDecision = 'FilterNotFound' } } return [PSCustomObject]@{ Applies = $true; FilterDecision = $filterDecision } } function Get-DeviceAppliedPolicies { <# Walks a list of policies (each carrying its full assignment set) and decides, for the given device, whether each policy: - applies -> goes in .Applied - was explicitly excluded -> goes in .Excluded with a reason ('ExcludeGroup' | 'AssignmentFilter') - simply doesn't target it -> ignored (not anomalous in itself) A policy that targeted the device's group/user but was then rejected by an assignment filter, or by an exclusion group, is an *intentional* exclusion - callers can hide those from anomaly output by default and surface them under an opt-in flag. #> param( [Parameter(Mandatory)] [AllowEmptyCollection()] [array] $Policies, [Parameter(Mandatory)] [PSCustomObject] $Device, [Parameter(Mandatory)] [PSCustomObject] $Context ) $applied = [System.Collections.Generic.List[object]]::new() $excluded = [System.Collections.Generic.List[object]]::new() $aadDeviceId = if ($Device.AzureAdDeviceId) { [string]$Device.AzureAdDeviceId } else { '' } $primaryUpn = if ($Device.PrimaryUser) { ([string]$Device.PrimaryUser).ToLowerInvariant() } else { '' } foreach ($policy in $Policies) { # First pass: any exclude group that names this device's identity wins # outright, regardless of include scope. $excludeGroupHit = $false foreach ($a in $policy.Assignments) { if ($a.AssignmentType -ne 'Group (Exclude)' -or -not $a.GroupId) { continue } $gid = [string]$a.GroupId if ($aadDeviceId -and $Context.GroupDeviceAadIds.ContainsKey($gid) -and $Context.GroupDeviceAadIds[$gid].Contains($aadDeviceId)) { $excludeGroupHit = $true; break } if ($primaryUpn -and $Context.GroupUserUpns.ContainsKey($gid) -and $Context.GroupUserUpns[$gid].Contains($primaryUpn)) { $excludeGroupHit = $true; break } } if ($excludeGroupHit) { $excluded.Add([PSCustomObject]@{ PolicyName = $policy.DisplayName; Source = $policy.Source; Reason = 'ExcludeGroup' }) continue } # Second pass: walk include-style assignments. Track whether any base-level # match happened (device was *targeted*) so we can distinguish a filter # rejection ("intentional exclusion") from a plain non-match ("never in scope"). $includeMatch = $null $baseTargetedButFiltered = $false foreach ($a in $policy.Assignments) { if ($a.AssignmentType -eq 'Group (Exclude)' -or $a.AssignmentType -eq 'Not Assigned') { continue } $result = Test-AssignmentAppliesToManagedDevice -Assignment $a -Device $Device -Context $Context if ($result.Applies) { $includeMatch = $result; break } # Applies=$false with a non-N/A FilterDecision == base matched, filter rejected. if ($result.FilterDecision -in 'Matched','NotMatched','FilterNotFound') { $baseTargetedButFiltered = $true } } if ($includeMatch) { $applied.Add([PSCustomObject]@{ PolicyName = $policy.DisplayName; Source = $policy.Source; FilterDecision = $includeMatch.FilterDecision }) } elseif ($baseTargetedButFiltered) { $excluded.Add([PSCustomObject]@{ PolicyName = $policy.DisplayName; Source = $policy.Source; Reason = 'AssignmentFilter' }) } # else: policy didn't target this device at all. Not surfaced. } return [PSCustomObject]@{ Applied = @($applied); Excluded = @($excluded) } } function Select-RKAnomalyWarning { <# .SYNOPSIS The collection warnings of the given areas. #> [CmdletBinding()] param([Parameter(Mandatory)] $Data, [Parameter(Mandatory)] [string[]] $Area) @($Data.Warnings | Where-Object { $_.Area -in $Area }) } function Resolve-IntuneInactiveDevices { <# .SYNOPSIS Windows devices whose last Intune sync is older than -Days (oldest first). Also carries the General warnings (a device that could not be processed). #> [CmdletBinding()] param([Parameter(Mandatory)] $Data, [int] $Days = 90) $cutoff = (Get-Date).AddDays(-$Days) # Same test as the report always used: a device without LastContact also counts as inactive. $rows = @($Data.Devices | Where-Object { $_.LastContact -lt $cutoff } | Sort-Object LastContact | Select-Object Customer, DeviceName, PrimaryUser, Serialnumber, DeviceManufacturer, DeviceModel, LastContact) [pscustomobject]@{ Rows = $rows; Warnings = Select-RKAnomalyWarning -Data $Data -Area 'General' } } function Resolve-IntuneDisabledPrimaryUsers { <# .SYNOPSIS Windows devices whose primary user is disabled in Entra ID. #> [CmdletBinding()] param([Parameter(Mandatory)] $Data) $disabled = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($u in $Data.Users) { if ($u.accountEnabled -eq $false -and $u.userPrincipalName) { [void]$disabled.Add([string]$u.userPrincipalName) } } $rows = @($Data.Devices | Where-Object { $_.PrimaryUser -and $disabled.Contains([string]$_.PrimaryUser) } | Sort-Object DeviceName | Select-Object Customer, DeviceName, PrimaryUser, Serialnumber, DeviceManufacturer, DeviceModel) [pscustomobject]@{ Rows = $rows; Warnings = Select-RKAnomalyWarning -Data $Data -Area 'General' } } function Resolve-IntuneMissingAutopilotDevices { <# .SYNOPSIS Physical Windows devices with neither an Autopilot hash nor a corporate identifier. #> [CmdletBinding()] param([Parameter(Mandatory)] $Data) $rows = @($Data.Devices | Where-Object { -not $_.IsCloudPC -and $_.DeviceHashUploaded -eq $false -and -not $_.DeviceAssociated } | Sort-Object DeviceName | Select-Object Customer, DeviceName, PrimaryUser, Serialnumber, DeviceManufacturer, DeviceModel) [pscustomobject]@{ Rows = $rows; Warnings = Select-RKAnomalyWarning -Data $Data -Area 'Autopilot', 'General' } } function Resolve-IntuneMultiUserDevices { <# .SYNOPSIS Devices enrolled with a single-user Autopilot profile on which more than one user signed in. #> [CmdletBinding()] param([Parameter(Mandatory)] $Data) $singleUser = @($Data.AutopilotProfiles | Where-Object { $_.outOfBoxExperienceSettings.deviceUsageType -eq 'SingleUser' } | ForEach-Object { $_.displayName }) $rows = @($Data.Devices | Where-Object { $_.usersLoggedOnCount -gt 1 -and $_.EnrollmentProfile -in $singleUser } | Sort-Object DeviceName | Select-Object Customer, DeviceName, PrimaryUser, EnrollmentProfile, usersLoggedOnCount, usersLoggedOnIds) [pscustomobject]@{ Rows = $rows; Warnings = Select-RKAnomalyWarning -Data $Data -Area 'General' } } function Resolve-IntuneNoncompliantDevices { <# .SYNOPSIS Noncompliant Windows devices, one row per compliance rule that failed. #> [CmdletBinding()] param([Parameter(Mandatory)] $Data) $rows = [System.Collections.Generic.List[object]]::new() foreach ($device in @($Data.Devices | Where-Object { $_.ComplianceStatus -eq 'noncompliant' } | Sort-Object DeviceName)) { $reasons = @(if ($device.NoncompliantBasedOn) { $device.NoncompliantBasedOn -split ', ' | ForEach-Object { $_.Trim() } | Where-Object { $_ } }) if ($reasons.Count -eq 0) { $reasons = @('Unknown') } foreach ($reason in $reasons) { $rows.Add([pscustomobject]@{ Customer = $device.Customer; DeviceName = $device.DeviceName; PrimaryUser = $device.PrimaryUser; Serialnumber = $device.Serialnumber; DeviceManufacturer = $device.DeviceManufacturer; DeviceModel = $device.DeviceModel; ComplianceStatus = $device.ComplianceStatus; NoncompliantBasedOn = $reason; NoncompliantAlert = $device.NoncompliantAlert }) } } [pscustomobject]@{ Rows = $rows.ToArray(); Warnings = Select-RKAnomalyWarning -Data $Data -Area 'Compliance', 'General' } } function Resolve-IntuneBitLockerAnomalies { <# For each Windows managed device, decide whether BitLocker is governed by an assigned Intune policy and whether the OS-volume recovery key is escrowed to Entra. Emits one row per anomalous device with a severity bucket. A device that was deliberately excluded (via an exclude group or an assignment filter that rejected it) is *not* an anomaly by default - that's the admin's explicit intent. Those devices still come back, as Info rows. #> [CmdletBinding()] param([Parameter(Mandatory)] $Data, [string] $TenantName) if (-not $TenantName) { $TenantName = $Data.TenantName } $Context = $Data.SecurityKeyContext # Only an explicit $false changes anything: without the keys, "no key in Entra" cannot be told from "not readable". $keysReadable = $Data.BitLockerKeysReadable -ne $false # Cloud PCs have platform-managed disk encryption. $Devices = @($Data.Devices | Where-Object { -not $_.IsCloudPC }) $out = [System.Collections.Generic.List[PSObject]]::new() foreach ($d in $Devices) { $aadId = Resolve-DeviceAzureAdDeviceId -Device $d -Context $Context $isEncrypted = [bool]$d.Encrypted # No Entra device id: we can't evaluate policy assignment or key escrow # for this device, but we still flag "not encrypted" since that's the # one anomaly we can detect from the managed-device record alone. if (-not $aadId) { if (-not $isEncrypted) { $out.Add([PSCustomObject][ordered]@{ Customer = $TenantName DeviceName = $d.DeviceName PrimaryUser = $d.PrimaryUser Serialnumber = $d.Serialnumber IsEncrypted = 'No' AppliedPolicies = '' KeyEscrowed = 'Unknown' Severity = 'Warning' Status = 'Device not encrypted (no Azure AD device id to verify policy / key state)' }) } continue } $eval = Get-DeviceAppliedPolicies -Policies $Context.BitLockerPolicies -Device $d -Context $Context $applied = $eval.Applied $excluded = $eval.Excluded $hasKey = $Context.BitLockerKeyDeviceIds.Contains($aadId) $status = $null; $severity = $null if ($applied.Count -eq 0 -and $excluded.Count -gt 0) { # Intentional exclusion, surfaced as Info. $reasons = ($excluded | Select-Object -ExpandProperty Reason -Unique) -join ', ' $status = "Intentionally excluded from BitLocker policies ($reasons)" $severity = 'Info' } elseif (-not $isEncrypted -and $applied.Count -eq 0) { $status = 'Device not encrypted and no BitLocker policy assigned'; $severity = 'Critical' } elseif ($applied.Count -eq 0) { $status = 'No BitLocker policy assigned'; $severity = 'Warning' } elseif ($isEncrypted -and -not $hasKey -and $keysReadable) { $status = 'Encrypted but no OS-volume key in Entra'; $severity = 'Critical' } elseif (-not $isEncrypted) { $status = 'BitLocker policy assigned but device not encrypted'; $severity = 'Critical' } else { continue } # Healthy. $out.Add([PSCustomObject][ordered]@{ Customer = $TenantName DeviceName = $d.DeviceName PrimaryUser = $d.PrimaryUser Serialnumber = $d.Serialnumber IsEncrypted = if ($isEncrypted) { 'Yes' } else { 'No' } AppliedPolicies = if ($applied.Count -gt 0) { ($applied | ForEach-Object { $_.PolicyName }) -join '; ' } else { ($excluded | ForEach-Object { "$($_.PolicyName) [$($_.Reason)]" }) -join '; ' } KeyEscrowed = if (-not $keysReadable) { 'Unknown' } elseif ($hasKey) { 'Yes' } else { 'No' } Severity = $severity Status = $status }) } [pscustomobject]@{ Rows = $out.ToArray(); Warnings = Select-RKAnomalyWarning -Data $Data -Area 'BitLocker', 'General' } } function Resolve-IntuneLapsAnomalies { <# For each Windows managed device, decide whether an Entra-backed Windows LAPS policy is assigned and whether a local admin credential is actually backed up. A backup older than -MaxBackupAgeDays is reported as a rotation gap. Devices that were explicitly excluded (exclude group or assignment filter) are surfaced as Info rows. #> [CmdletBinding()] param([Parameter(Mandatory)] $Data, [string] $TenantName, [int] $MaxBackupAgeDays = 60) if (-not $TenantName) { $TenantName = $Data.TenantName } $Context = $Data.SecurityKeyContext # LAPS covers every device. $Devices = @($Data.Devices) $out = [System.Collections.Generic.List[PSObject]]::new() $cutoff = (Get-Date).AddDays(-$MaxBackupAgeDays) # Only an explicit $false changes anything: without the credentials, "no backup" cannot be told from "not readable". $credentialsReadable = $Data.LapsCredentialsReadable -ne $false foreach ($d in $Devices) { $aadId = Resolve-DeviceAzureAdDeviceId -Device $d -Context $Context if (-not $aadId) { continue } $eval = Get-DeviceAppliedPolicies -Policies $Context.LapsPolicies -Device $d -Context $Context $applied = $eval.Applied $excluded = $eval.Excluded # Primary join: LAPS credentials are keyed by the Azure AD device id. # Secondary join (deviceName) covers the rare case where the device id we # have differs from the one Entra stored the LAPS entry against. $entry = $Context.LapsCredentialByDeviceId[$aadId] if (-not $entry -and $d.DeviceName) { $nameKey = ([string]$d.DeviceName).ToLowerInvariant() if ($Context.LapsCredentialByDeviceName.ContainsKey($nameKey)) { $entry = $Context.LapsCredentialByDeviceName[$nameKey] } } $status = $null; $severity = $null $lastBackup = $null; $ageDays = $null if ($entry -and $entry.lastBackupDateTime) { try { $lastBackup = [datetime]$entry.lastBackupDateTime; $ageDays = [int](((Get-Date) - $lastBackup).TotalDays) } catch { } } if ($applied.Count -eq 0 -and $excluded.Count -gt 0) { $reasons = ($excluded | Select-Object -ExpandProperty Reason -Unique) -join ', ' $status = "Intentionally excluded from LAPS policies ($reasons)" $severity = 'Info' } elseif ($applied.Count -eq 0) { $status = 'No Entra-backed LAPS policy assigned'; $severity = 'Warning' } elseif (-not $entry -and $credentialsReadable) { $status = 'LAPS policy assigned but no credential backed up to Entra'; $severity = 'Critical' } elseif ($lastBackup -and $lastBackup -lt $cutoff) { $status = "LAPS backup stale (> $MaxBackupAgeDays days, rotation may be stalled)"; $severity = 'Warning' } else { continue } # Healthy. $out.Add([PSCustomObject][ordered]@{ Customer = $TenantName DeviceName = $d.DeviceName PrimaryUser = $d.PrimaryUser Serialnumber = $d.Serialnumber OwnerType = $d.DeviceOwnership AppliedPolicies = if ($applied.Count -gt 0) { ($applied | ForEach-Object { $_.PolicyName }) -join '; ' } else { ($excluded | ForEach-Object { "$($_.PolicyName) [$($_.Reason)]" }) -join '; ' } LastBackupDateTime = if ($lastBackup) { $lastBackup.ToString('yyyy-MM-dd HH:mm') } else { '' } BackupAgeDays = if ($null -ne $ageDays) { $ageDays } else { '' } Severity = $severity Status = $status }) } [pscustomobject]@{ Rows = $out.ToArray(); Warnings = Select-RKAnomalyWarning -Data $Data -Area 'Laps', 'General' } } # ---------------------------------------------------------------------------- # Deprecated Intune settings discovery. # Walks every Settings Catalog policy already fetched into the context by # Get-BitLockerLapsAssignmentContext and flags individual settings that # Microsoft has marked as deprecated. Detection sources, in order of precedence: # 1. Catalog DisplayName for the settingDefinitionId contains "deprecated" # 2. The setting's raw definition id contains "deprecated" # 3. The configured value string contains "deprecated" # Display names come from the $expand=settingDefinitions read of each policy's # settings (done by the collector); when a setting has no definition the walker # still detects the subset that carries "deprecated" in the raw id or value # string. # ---------------------------------------------------------------------------- function Test-IsSettingDeprecated { <# Returns @{ IsDeprecated; Source; DisplayName } where Source is one of 'CatalogDisplayName' | 'DefinitionIdMatch' | 'ValueMatch' on a hit. #> [CmdletBinding()] param( [string] $DefinitionId, [string] $Value, [hashtable]$Catalog ) if ($Catalog -and $Catalog.Count -gt 0 -and -not [string]::IsNullOrEmpty($DefinitionId) -and $Catalog.ContainsKey($DefinitionId)) { $dn = [string]$Catalog[$DefinitionId].DisplayName if ($dn -match '(?i)deprecated') { return [PSCustomObject]@{ IsDeprecated = $true; Source = 'CatalogDisplayName'; DisplayName = $dn } } } if (-not [string]::IsNullOrEmpty($DefinitionId) -and $DefinitionId -match '(?i)deprecated') { $dn = if ($Catalog -and $Catalog.ContainsKey($DefinitionId)) { [string]$Catalog[$DefinitionId].DisplayName } else { $DefinitionId } return [PSCustomObject]@{ IsDeprecated = $true; Source = 'DefinitionIdMatch'; DisplayName = $dn } } if (-not [string]::IsNullOrEmpty($Value) -and $Value -match '(?i)deprecated') { $dn = if ($Catalog -and $Catalog.ContainsKey($DefinitionId)) { [string]$Catalog[$DefinitionId].DisplayName } else { $DefinitionId } return [PSCustomObject]@{ IsDeprecated = $true; Source = 'ValueMatch'; DisplayName = $dn } } return [PSCustomObject]@{ IsDeprecated = $false } } function Get-IntuneSettingValueString { <# Extract the configured value(s) from a settingInstance, regardless of OData sub-type (choice / simple / collection). Returns a string suitable for display and for the deprecation regex check. Group / collection instances return empty here; their children are walked separately by the caller. #> [CmdletBinding()] [OutputType([string])] param([Parameter(Mandatory)] $SettingInstance) $type = [string]$SettingInstance.'@odata.type' switch -Wildcard ($type) { '*choiceSettingInstance*' { return [string]$SettingInstance.choiceSettingValue.value } '*simpleSettingInstance*' { return [string]$SettingInstance.simpleSettingValue.value } '*choiceSettingCollectionInstance*' { $vals = @() foreach ($cv in @($SettingInstance.choiceSettingCollectionValue)) { if ($cv.value) { $vals += [string]$cv.value } } return ($vals -join '; ') } '*simpleSettingCollectionInstance*' { $vals = @() foreach ($sv in @($SettingInstance.simpleSettingCollectionValue)) { if ($sv.value) { $vals += [string]$sv.value } } return ($vals -join '; ') } default { return '' } } } function Get-IntuneSettingDeprecations { <# Recursively walk a settingInstance (and any nested group children) and return one record per deprecated setting found: { DefinitionId; DisplayName; Value; Source } #> [CmdletBinding()] param( [Parameter(Mandatory)] $SettingInstance, [hashtable] $Catalog ) $hits = [System.Collections.Generic.List[object]]::new() if (-not $SettingInstance) { return $hits } $defId = [string]$SettingInstance.settingDefinitionId $value = Get-IntuneSettingValueString -SettingInstance $SettingInstance $check = Test-IsSettingDeprecated -DefinitionId $defId -Value $value -Catalog $Catalog if ($check.IsDeprecated) { $hits.Add([PSCustomObject]@{ DefinitionId = $defId DisplayName = if ([string]::IsNullOrWhiteSpace($check.DisplayName)) { $defId } else { $check.DisplayName } Value = $value Source = $check.Source }) } $type = [string]$SettingInstance.'@odata.type' if ($type -like '*groupSettingCollectionInstance*') { foreach ($child in @($SettingInstance.groupSettingCollectionValue)) { foreach ($childSetting in @($child.children | Where-Object { $_ })) { foreach ($h in (Get-IntuneSettingDeprecations -SettingInstance $childSetting -Catalog $Catalog)) { $hits.Add($h) } } } } elseif ($type -like '*groupSettingInstance*') { foreach ($childSetting in @($SettingInstance.groupSettingValue.children | Where-Object { $_ })) { foreach ($h in (Get-IntuneSettingDeprecations -SettingInstance $childSetting -Catalog $Catalog)) { $hits.Add($h) } } } return $hits } function Resolve-IntuneDeprecatedAnomalies { <# Iterates the Settings Catalog policies already on the data object, scans every setting against the expanded setting definitions, and emits one row per deprecated setting in use. #> [CmdletBinding()] param([Parameter(Mandatory)] $Data, [string] $TenantName) if (-not $TenantName) { $TenantName = $Data.TenantName } $Context = $Data.SecurityKeyContext Write-Verbose 'Scanning Settings Catalog policies for deprecated settings...' # Display names come from configurationPolicies/{id}/settings?$expand=settingDefinitions, read by the collector. $catalog = @{} foreach ($policy in $Context.ConfigurationPolicies) { foreach ($s in @($policy.settings)) { foreach ($def in @($s.settingDefinitions)) { if ($def.id -and -not $catalog.ContainsKey([string]$def.id)) { $catalog[[string]$def.id] = [pscustomobject]@{ DisplayName = [string]$def.displayName } } } } } $out = [System.Collections.Generic.List[PSObject]]::new() foreach ($policy in $Context.ConfigurationPolicies) { if (-not $policy.settings) { continue } $policyName = if ($policy.name) { $policy.name } elseif ($policy.displayName) { $policy.displayName } else { '(unnamed)' } $platform = if ($policy.platforms) { [string]$policy.platforms } else { '' } $technologies = if ($policy.technologies) { [string]$policy.technologies } else { '' } foreach ($s in @($policy.settings)) { if (-not $s.settingInstance) { continue } foreach ($hit in (Get-IntuneSettingDeprecations -SettingInstance $s.settingInstance -Catalog $catalog)) { $out.Add([PSCustomObject]@{ Customer = $TenantName PolicyName = $policyName PolicyId = $policy.id Platform = $platform Technologies = $technologies SettingDisplayName = $hit.DisplayName SettingDefinitionId = $hit.DefinitionId ConfiguredValue = $hit.Value DetectionSource = $hit.Source }) } } } Write-Verbose "Found $($out.Count) deprecated setting instance(s) across $($Context.ConfigurationPolicies.Count) Settings Catalog policies." [pscustomobject]@{ Rows = @($out | Sort-Object PolicyName, SettingDisplayName); Warnings = Select-RKAnomalyWarning -Data $Data -Area 'Settings' } } |