public/Get-MsecIntuneAsrRule.ps1
|
function Get-MsecIntuneAsrRule { <# .SYNOPSIS Every Attack Surface Reduction rule, the mode it is set to, which policy sets it, who that policy reaches - and the rules no policy configures at all. .DESCRIPTION ASR rules are configured as settings INSIDE endpoint security policies, so neither "list the policies" nor "list the settings" answers the question anyone actually has, which is "which rules are we enforcing, on whom, and which are we not". This flattens the policies into one row per rule per policy and fills in the gaps. THE RULES NOBODY CONFIGURED ARE THE POINT, AND THEY ARE INVISIBLE IN THE PORTAL. A policy blade shows the rules that policy sets; a rule set by no policy appears nowhere, so the gap can only be found by diffing against the full catalogue by hand. Every rule is emitted, with Configured = $false and Mode = $null where nothing sets it. Measured on one tenant: two baseline policies carrying 16 rules each, and 'Block rebooting machine in Safe Mode' configured in neither - a gap invisible from either policy blade. THE CATALOGUE COMES FROM GRAPH, NOT FROM A LIST IN THIS FILE. The rule set is read from the setting definitions, so a rule Microsoft adds appears here the day it ships instead of being silently absent until somebody updates the module. Only the GUIDs are local - they are not in the definitions and are what documentation and PowerShell use - and a rule with no GUID mapping is still emitted, with RuleId $null, rather than dropped. MODE IS NOT A BOOLEAN AND 'OFF' IS NOT 'NOT CONFIGURED'. A rule explicitly set to off in a policy that reaches a device beats a rule left unconfigured, because the explicit value wins conflict resolution. Those two are different rows here - Mode 'off' with Configured $true, against Mode $null with Configured $false - and conflating them is how a deliberate carve-out gets mistaken for an oversight and quietly 'fixed'. TWO MODES IS NOT AUTOMATICALLY A CONFLICT. The most common deliberate ASR design is a rule in audit for one group and block for everyone else, with the two policies excluding each other's groups - measured on one tenant, the single rule set to two modes was exactly that. So ModesDiffer is the fact, and Conflicting is the judgement: it is $true only where the policies do NOT carve each other out and a device may therefore receive both, which Intune resolves silently with the losing value shown in neither blade. Proving real overlap would need group membership evaluation and this does not do it, so Conflicting is deliberately the weaker claim of the two. PER-RULE EXCLUSIONS TRAVEL WITH THE RULE. Each ASR setting carries its own exclusion list, and an exclusion is a deliberate hole in a control - it belongs next to the mode, not three blades away. Excluded paths are projected to PerRuleExclusion. That list is nested UNDER the rule's own value rather than beside it, which the setting id ('<rule>_perruleexclusions') does not suggest. Read at the wrong depth it comes back empty, so a policy carrying a real exclusion reports none - measured on one tenant against a live Git exclusion. The subtree is walked rather than indexed at a fixed depth. ONLY SETTINGS CATALOG AND ENDPOINT SECURITY POLICIES ARE PARSED. ASR can also be set through the older intents API, classic endpoint protection profiles, Group Policy or local PowerShell, and none of those are read here. Where the tenant has any of the first two, a warning names them, because an unqualified Configured = $false would be a claim this command cannot support. .PARAMETER Rule Only rules whose name or slug matches this substring, case-insensitive. 'safe mode', 'psexec', 'office'. .PARAMETER Mode Only rules set to this mode: 'block', 'audit', 'warn' or 'off'. .PARAMETER ConfiguredOnly Drop the rules no policy configures. Off by default, because those rows are usually the reason to run this. .PARAMETER NoGroupNameLookup Report assignment group ids instead of resolving their display names. One Graph call per distinct group is spent on the lookup otherwise, cached across the run. .EXAMPLE Get-MsecIntuneAsrRule | Where-Object { -not $_.Configured } The rules no policy sets. The gap no portal blade will show you. .EXAMPLE Get-MsecIntuneAsrRule | Where-Object Conflicting | Sort-Object RuleName | Format-Table RuleName, Mode, PolicyName Rules set to different modes by two policies. Intune picks a winner silently. .EXAMPLE Get-MsecIntuneAsrRule -Mode audit What is being measured rather than enforced - the staging area of an ASR rollout, and the set most likely to have been left there and forgotten. .EXAMPLE Get-MsecIntuneAsrRule | Where-Object PerRuleExclusion | Select-Object RuleName, PolicyName, PerRuleExclusion Every deliberate hole in an ASR rule, with the policy it lives in. .OUTPUTS PSCustomObject per rule-and-policy pair, PSTypeName 'MsecIntuneAsrRule'. .NOTES Needs 'DeviceManagementConfigurationPolicy.Read.All' or 'DeviceManagementConfiguration.Read.All', which New-MsecApp grants, plus Group.Read.All for the assignment group names. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [string] $Rule, [ArgumentCompleter({ param($c, $p, $wordToComplete) @('block', 'audit', 'warn', 'off') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } })] [string] $Mode, [switch] $ConfiguredOnly, [switch] $NoGroupNameLookup ) Assert-MsecSession $base = 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules' # The catalogue, from Graph. A hardcoded list would go stale the next time Microsoft ships # a rule, and the failure mode is the worst one available here: a new rule would be absent # from the output entirely rather than reported as unconfigured. $catalogue = @{} try { foreach ($def in @(Invoke-MsecGraphRequest -Path "/beta/deviceManagement/configurationSettings?`$filter=startswith(id,'$base')" -All)) { $id = [string] $def.id if (-not $id.StartsWith("$base`_", [StringComparison]::OrdinalIgnoreCase)) { continue } $slug = $id.Substring($base.Length + 1) # Each rule also has a '<slug>_perruleexclusions' definition. That is the rule's # exclusion list, not a rule of its own, and must not become a catalogue entry. if ($slug -match '_perruleexclusions$') { continue } $catalogue[$slug] = [string] $def.displayName } } catch { if ("$($_.Exception.Message)" -match '403|Forbidden') { throw "Forbidden reading the ASR setting definitions. The msec app needs 'DeviceManagementConfiguration.Read.All'. Re-run New-MsecApp if it is missing. Original error: $($_.Exception.Message)" } throw } if (-not $catalogue.Count) { Write-Warning "No ASR setting definitions were returned by Graph, so the full rule catalogue is unknown and UNCONFIGURED RULES CANNOT BE REPORTED. Only rules found in a policy will be listed." } # GUIDs are what Microsoft's documentation, PowerShell and the registry use, and they are # not in the setting definitions. Local, and additive only: a slug missing from this map # still produces a row, with RuleId $null. $guids = @{ 'blockabuseofexploitedvulnerablesigneddrivers' = '56a863a9-875e-4185-98a7-b882c64b5ce5' 'blockcredentialstealingfromwindowslocalsecurityauthoritysubsystem' = '9e6c4e1f-7d60-472f-ba1a-a39ef669e4b2' 'blockpersistencethroughwmieventsubscription' = 'e6db77e5-3df2-4cf1-b95a-636979351e5b' 'blockadobereaderfromcreatingchildprocesses' = '7674ba52-37eb-4a4f-a9a1-f0f9a1619a2c' 'blockallofficeapplicationsfromcreatingchildprocesses' = 'd4f940ab-401b-4efc-aadc-ad5f3c50688a' 'blockexecutablecontentfromemailclientandwebmail' = 'be9ba2d9-53ea-4cdc-84e5-9b1eeee46550' 'blockexecutablefilesrunningunlesstheymeetprevalenceagetrustedlistcriterion'= '01443614-cd74-433a-b99e-2ecdc07bfc25' 'blockexecutionofpotentiallyobfuscatedscripts' = '5beb7efe-fd9a-4556-801d-275e5ffc04cc' 'blockjavascriptorvbscriptfromlaunchingdownloadedexecutablecontent' = 'd3e037e1-3eb8-44c8-a917-57927947596d' 'blockofficeapplicationsfromcreatingexecutablecontent' = '3b576869-a4ec-4529-8536-b80a7769e899' 'blockofficeapplicationsfrominjectingcodeintootherprocesses' = '75668c1f-73b5-4cf0-bb93-3ecf5cb7cc84' 'blockofficecommunicationappfromcreatingchildprocesses' = '26190899-1602-49e8-8b27-eb1d0a1ce869' 'blockprocesscreationsfrompsexecandwmicommands' = 'd1e49aac-8f56-4280-b9ba-993a6d77406c' 'blockrebootingmachineinsafemode' = '33ddedf1-c6e0-47cb-833e-de6133960387' 'blockuntrustedunsignedprocessesthatrunfromusb' = 'b2b3f03d-6a65-4f7b-a9c7-1c7ef74a9ba4' 'blockuseofcopiedorimpersonatedsystemtools' = 'c0033c00-d16d-4114-a5a0-dc9b3a7d2ceb' 'blockwebshellcreationforservers' = 'a8f5898e-1dc8-49a9-9878-85004b8a61e6' 'blockwin32apicallsfromofficemacros' = '92e97fa1-2edf-4476-bdd6-9dd0b4dddc7b' 'useadvancedprotectionagainstransomware' = 'c1db55ab-c21a-4637-bb3f-a12568109d35' } # Microsoft's own 'standard protection' set - the three it recommends enabling in Block # without extensive testing. Worth distinguishing from the rest when triaging a gap. $standard = @( 'blockabuseofexploitedvulnerablesigneddrivers' 'blockcredentialstealingfromwindowslocalsecurityauthoritysubsystem' 'blockpersistencethroughwmieventsubscription' ) $policies = @(Invoke-MsecGraphRequest -Path "/beta/deviceManagement/configurationPolicies?`$expand=assignments" -All) | Where-Object { "$($_.templateReference.templateFamily)" -eq 'endpointSecurityAttackSurfaceReduction' } # ASR set anywhere but a Settings Catalog / endpoint security policy is not read here, so # say so rather than let Configured=$false imply a completeness this cannot deliver. $legacy = @() try { $intents = @(Invoke-MsecGraphRequest -Path '/beta/deviceManagement/intents?$select=id,displayName' -All) if ($intents.Count) { $legacy += "$($intents.Count) endpoint security intent(s) ($(($intents | ForEach-Object { $_.displayName }) -join ', '))" } } catch { Write-Verbose "Could not check intents: $($_.Exception.Message)" } try { $classic = @(Invoke-MsecGraphRequest -Path '/v1.0/deviceManagement/deviceConfigurations' -All) | Where-Object { "$($_.'@odata.type')" -match 'EndpointProtection' } if ($classic.Count) { $legacy += "$($classic.Count) classic endpoint protection profile(s)" } } catch { Write-Verbose "Could not check deviceConfigurations: $($_.Exception.Message)" } $groupNames = @{} function Resolve-GroupName([string] $GroupId) { if (-not $GroupId) { return $null } if ($NoGroupNameLookup) { return $GroupId } if ($groupNames.ContainsKey($GroupId)) { return $groupNames[$GroupId] } # One request per group, not a batched $filter=id in (...): a filtered list silently # OMITS ids that no longer exist, so a deleted group would be indistinguishable from an # unreadable one. A 404 says which. try { $groupNames[$GroupId] = [string] (Invoke-MsecGraphRequest -Path "/v1.0/groups/$GroupId`?`$select=displayName").displayName } catch { $groupNames[$GroupId] = "(group $GroupId not readable)" } if (-not $groupNames[$GroupId]) { $groupNames[$GroupId] = "(group $GroupId has no name)" } $groupNames[$GroupId] } # Pass one: collect every (slug, policy) pair, so PolicyCount and Conflicting can be filled # in before anything is emitted. A conflict is only visible across policies. $found = @() foreach ($policy in $policies) { $settings = @() try { $settings = @(Invoke-MsecGraphRequest -Path "/beta/deviceManagement/configurationPolicies/$($policy.id)/settings" -All) } catch { Write-Warning "Could not read settings from ASR policy '$($policy.name)', so its rules are MISSING from this output rather than reported as unconfigured: $($_.Exception.Message)" continue } $included = @(); $excluded = @(); $allUsers = $false; $allDevices = $false foreach ($a in @($policy.assignments)) { # EVERY BRANCH BREAKS, and the exclusion test comes first. PowerShell's switch runs # every matching branch, not just the first, and 'exclusionGroupAssignmentTarget' # also matches the wildcard '*groupAssignmentTarget' - so without these the carve-out # groups land in the INCLUDED list as well, and 'everyone except developers' reads as # 'everyone, including developers'. That inverts the meaning of the policy. switch -Wildcard ("$($a.target.'@odata.type')") { '*allLicensedUsersAssignmentTarget' { $allUsers = $true; break } '*allDevicesAssignmentTarget' { $allDevices = $true; break } '*exclusionGroupAssignmentTarget' { $excluded += (Resolve-GroupName $a.target.groupId); break } '*groupAssignmentTarget' { $included += (Resolve-GroupName $a.target.groupId); break } } } foreach ($setting in $settings) { # DEVICE CONTROL POLICIES LIVE IN THE SAME TEMPLATE FAMILY. Intune files USB and # device control under Attack Surface Reduction, so templateFamily alone does not # mean 'ASR rules' - their settings carry entirely different definition ids, and # slicing those at $base.Length produced rows named 'uleid}_ruledata'. The prefix # test is what separates the two; the length test alone is not enough. foreach ($child in @($setting.settingInstance.groupSettingCollectionValue.children)) { $defId = [string] $child.settingDefinitionId if (-not $defId.StartsWith("$base`_", [StringComparison]::OrdinalIgnoreCase)) { continue } $slug = $defId.Substring($base.Length + 1) if ($slug -match '_perruleexclusions$') { continue } # The mode is the last segment of the choice value, which repeats the whole # definition id as a prefix: '..._blockrebootingmachineinsafemode_block'. $value = [string] $child.choiceSettingValue.value $ruleMode = if ($value) { ($value -split '_')[-1] } else { $null } # THE EXCLUSION LIST IS NESTED UNDER THE RULE, NOT BESIDE IT. The first version # of this looked for '<slug>_perruleexclusions' as a SIBLING in the same children # collection, which is the shape the id suggests and is wrong: Intune hangs it # off the rule's own choiceSettingValue, one level deeper. The symptom was a # policy with a real exclusion reporting none - the exact failure this module is # supposed to prevent, found only because somebody added one and it did not show. # # So the search walks the rule's subtree for the id rather than assuming a depth, # and still accepts a sibling in case the shape differs elsewhere. $exclusionId = "$defId`_perruleexclusions" $exclusions = @( $stack = [System.Collections.Generic.Stack[object]]::new() $stack.Push($child) foreach ($sib in @($setting.settingInstance.groupSettingCollectionValue.children)) { if ("$($sib.settingDefinitionId)" -eq $exclusionId) { $stack.Push($sib) } } while ($stack.Count) { $node = $stack.Pop() if ($null -eq $node) { continue } if ($node -is [System.Collections.IEnumerable] -and $node -isnot [string]) { foreach ($i in $node) { $stack.Push($i) } continue } if ($node -isnot [psobject]) { continue } if ("$($node.settingDefinitionId)" -eq $exclusionId) { foreach ($v in @($node.simpleSettingCollectionValue)) { if ($null -ne $v.value) { [string] $v.value } } } foreach ($prop in $node.PSObject.Properties) { if ($prop.Value -is [psobject] -or ($prop.Value -is [System.Collections.IEnumerable] -and $prop.Value -isnot [string])) { $stack.Push($prop.Value) } } } ) | Sort-Object -Unique $found += [pscustomobject]@{ Slug = $slug Mode = $ruleMode Policy = $policy Included = @($included) Excluded = @($excluded) AllUsers = $allUsers AllDevices = $allDevices Exclusions = @($exclusions) Raw = $child } } } } $bySlug = $found | Group-Object Slug $modesBySlug = @{} $conflictBySlug = @{} foreach ($g in $bySlug) { $modesBySlug[$g.Name] = @($g.Group | ForEach-Object { $_.Mode } | Sort-Object -Unique) # TWO MODES IS NOT AUTOMATICALLY A CONFLICT, and reporting it as one cries wolf at the # most common deliberate ASR design there is: a rule in audit for a group and in block # for everybody else, with the two policies carving each other out. Measured on one # tenant, the only rule set to two modes was exactly that, and flagging it would have # sent someone to "fix" a working carve-out. # # A conflict needs the same DEVICE to receive both. Proving that needs group membership # evaluation, which this command does not do; proving the SEPARATION is cheap and is the # case worth recognising - policy A includes a group that policy B explicitly excludes. # So: separated pairs are not conflicts, everything else is reported as a POSSIBLE one. $conflictBySlug[$g.Name] = $false if (@($modesBySlug[$g.Name]).Count -le 1) { continue } $hitsForSlug = @($g.Group) foreach ($a in $hitsForSlug) { foreach ($b in $hitsForSlug) { if ([object]::ReferenceEquals($a, $b)) { continue } if ("$($a.Mode)" -eq "$($b.Mode)") { continue } $separated = (@($a.Included | Where-Object { $b.Excluded -contains $_ }).Count -gt 0) -or (@($b.Included | Where-Object { $a.Excluded -contains $_ }).Count -gt 0) if (-not $separated) { $conflictBySlug[$g.Name] = $true } } } } $conflicts = @($bySlug | Where-Object { $conflictBySlug[$_.Name] }) $separatedRules = @($bySlug | Where-Object { @($modesBySlug[$_.Name]).Count -gt 1 -and -not $conflictBySlug[$_.Name] }) # Every slug the tenant uses, plus every slug in the catalogue - so a rule Graph knows about # but no policy sets still gets a row, and a rule in a policy but missing from the catalogue # (a Microsoft addition this definitions call did not return) is never dropped. $allSlugs = @(@($catalogue.Keys) + @($bySlug | ForEach-Object { $_.Name }) | Sort-Object -Unique) foreach ($slug in $allSlugs) { $name = if ($catalogue.ContainsKey($slug)) { $catalogue[$slug] } else { $slug } if ($Rule -and "$name $slug" -notmatch [regex]::Escape($Rule)) { continue } $hits = @($found | Where-Object { $_.Slug -eq $slug }) if (-not $hits.Count) { if ($ConfiguredOnly -or $Mode) { continue } [PSCustomObject]@{ PSTypeName = 'MsecIntuneAsrRule' RuleName = $name RuleSlug = $slug RuleId = $(if ($guids.ContainsKey($slug)) { $guids[$slug] } else { $null }) StandardProtection = ($standard -contains $slug) # $null, not 'off'. An unconfigured rule and one explicitly set to off are # different states and only the second one wins a policy conflict. Mode = $null Configured = $false PolicyName = $null PolicyId = $null PolicyLastModified = $null AssignedGroup = @() ExcludedGroup = @() AssignedAllUsers = $false AssignedAllDevices = $false PerRuleExclusion = @() PolicyCount = 0 ModesDiffer = $false Conflicting = $false Raw = $null } continue } foreach ($hit in $hits) { if ($Mode -and "$($hit.Mode)" -ne $Mode) { continue } [PSCustomObject]@{ PSTypeName = 'MsecIntuneAsrRule' RuleName = $name RuleSlug = $slug RuleId = $(if ($guids.ContainsKey($slug)) { $guids[$slug] } else { $null }) StandardProtection = ($standard -contains $slug) Mode = $hit.Mode Configured = $true PolicyName = [string] $hit.Policy.name PolicyId = [string] $hit.Policy.id PolicyLastModified = $(if ($hit.Policy.lastModifiedDateTime) { [datetime] $hit.Policy.lastModifiedDateTime } else { $null }) AssignedGroup = $hit.Included ExcludedGroup = $hit.Excluded AssignedAllUsers = $hit.AllUsers AssignedAllDevices = $hit.AllDevices PerRuleExclusion = $hit.Exclusions PolicyCount = $hits.Count # Set in more than one policy, in more than one mode. ModesDiffer = (@($modesBySlug[$slug]).Count -gt 1) # ...and the policies are NOT carving each other out, so a device may get both. Conflicting = [bool] $conflictBySlug[$slug] Raw = $hit.Raw } } } if (-not $policies.Count) { Write-Warning "No endpoint security Attack Surface Reduction policies were found in this tenant. Every rule is therefore reported as unconfigured - which is only the whole answer if ASR is not set through any of the surfaces this command does not read (see below)." } if ($conflicts.Count) { Write-Warning "$($conflicts.Count) ASR rule(s) are set to different modes by policies that do NOT carve each other out, so a device may receive both and Intune will resolve it silently: $(($conflicts | ForEach-Object { $_.Name }) -join ', '). Filter on Conflicting to see them." } if ($separatedRules.Count) { Write-Verbose "$($separatedRules.Count) rule(s) are set to different modes in different policies, but those policies exclude each other's groups - the usual deliberate ring design, reported as ModesDiffer rather than Conflicting: $(($separatedRules | ForEach-Object { $_.Name }) -join ', ')." } if ($legacy.Count) { Write-Warning "ASR can also be configured outside the policies this command reads, and this tenant has $($legacy -join ' and '). Rules reported as Configured = `$false may be set there, or through Group Policy or local PowerShell, none of which are read here." } } |