tests/Get-MsecIntuneAsrRule.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecIntuneAsrRule. The function reads the ASR setting definitions for the rule
# catalogue, then every endpoint-security ASR policy, and flattens them to one row per rule per
# policy - plus a row per rule that no policy configures.
#
# Each test covers a defect that was found against a live tenant while writing it:
# - Device Control policies share the ASR templateFamily, and their settings sliced at the ASR
# prefix length produced rows named 'uleid}_ruledata'.
# - 'exclusionGroupAssignmentTarget' also matches the wildcard '*groupAssignmentTarget', and
# PowerShell's switch runs every matching branch - so carve-out groups landed in the
# INCLUDED list and 'everyone except developers' read as 'everyone'.
# - Two modes for one rule is usually a deliberate ring design, not a conflict.
# - A rule no policy configures must still be emitted, with Mode $null rather than 'off'.

BeforeAll {
    $modulePath = Join-Path $PSScriptRoot '..' 'Msec.psm1'
    Import-Module $modulePath -Force -ErrorAction Stop
    $script:TestThumbBytes = [byte[]](1..20)
}

AfterAll { Remove-Module Msec -Force -ErrorAction SilentlyContinue }

Describe 'Get-MsecIntuneAsrRule' {
    BeforeEach {
        InModuleScope Msec -Parameters @{ Thumb = $script:TestThumbBytes } {
            param($Thumb)
            $script:MsecSession = @{
                TenantId = 'tenant'; ClientId = 'client'; KeyVaultName = 'kv-test'
                KeyName = 'msec-app'; ThumbprintBytes = $Thumb; Tokens = @{}
            }

            $base = 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules'

            function script:New-AsrChild([string]$Slug, [string]$Mode) {
                [pscustomobject]@{
                    settingDefinitionId = "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_$Slug"
                    choiceSettingValue  = [pscustomobject]@{ value = "device_vendor_msft_policy_config_defender_attacksurfacereductionrules_${Slug}_$Mode" }
                }
            }
            function script:New-AsrPolicy([string]$Id, [string]$Name, [object[]]$Children, [object[]]$Assignments) {
                [pscustomobject]@{
                    id = $Id; name = $Name; lastModifiedDateTime = '2026-05-12T11:07:46Z'
                    templateReference = [pscustomobject]@{ templateFamily = 'endpointSecurityAttackSurfaceReduction' }
                    assignments = $Assignments
                    settings = @([pscustomobject]@{ settingInstance = [pscustomobject]@{
                        groupSettingCollectionValue = @([pscustomobject]@{ children = $Children }) } })
                }
            }
            function script:Incl([string]$Name) { [pscustomobject]@{ target = [pscustomobject]@{ '@odata.type' = '#microsoft.graph.groupAssignmentTarget'; groupId = $Name } } }
            function script:Excl([string]$Name) { [pscustomobject]@{ target = [pscustomobject]@{ '@odata.type' = '#microsoft.graph.exclusionGroupAssignmentTarget'; groupId = $Name } } }
            function script:AllUsers { [pscustomobject]@{ target = [pscustomobject]@{ '@odata.type' = '#microsoft.graph.allLicensedUsersAssignmentTarget' } } }

            Mock Invoke-MsecKeyVaultSign -MockWith { [byte[]](1..10) }
            Mock Invoke-RestMethod -ParameterFilter { $Uri -match 'oauth2/v2.0/token' } -MockWith {
                [pscustomobject]@{ access_token = 'mock'; expires_in = 3600 }
            }
            # Group name lookup is identity here: the fixtures use the name as the id.
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match '^/v1\.0/groups/' } -MockWith {
                [pscustomobject]@{ displayName = ($Path -replace '^/v1\.0/groups/', '' -replace '\?.*$', '') }
            }
            # Catalogue: three real rules plus the per-rule-exclusion sibling that must NOT
            # become a rule of its own.
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationSettings' } -MockWith {
                @(
                    [pscustomobject]@{ id = "${base}_blockrebootingmachineinsafemode";   displayName = 'Block rebooting machine in Safe Mode' }
                    [pscustomobject]@{ id = "${base}_blockuseofcopiedorimpersonatedsystemtools"; displayName = 'Block use of copied or impersonated system tools' }
                    [pscustomobject]@{ id = "${base}_blockexecutablefilesrunningunlesstheymeetprevalenceagetrustedlistcriterion"; displayName = 'Block executable files from running unless they meet a prevalence, age, or trusted list criterion' }
                    [pscustomobject]@{ id = "${base}_blockrebootingmachineinsafemode_perruleexclusions"; displayName = 'ASR Only Per Rule Exclusions' }
                )
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'deviceManagement/intents' } -MockWith { @() }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'deviceConfigurations' } -MockWith { @() }
        }
    }

    It 'reports a rule no policy configures, with Mode null rather than off' {
        $rows = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationPolicies\?' } -MockWith {
                @(New-AsrPolicy 'p1' 'ASR Developers' @(New-AsrChild 'blockuseofcopiedorimpersonatedsystemtools' 'block') @(Incl 'Devs'))
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationPolicies/p1/settings' } -MockWith {
                @([pscustomobject]@{ settingInstance = [pscustomobject]@{ groupSettingCollectionValue = @([pscustomobject]@{
                    children = @(New-AsrChild 'blockuseofcopiedorimpersonatedsystemtools' 'block') }) } })
            }
            , @(Get-MsecIntuneAsrRule)
        }

        $safeMode = $rows | Where-Object RuleSlug -eq 'blockrebootingmachineinsafemode'
        $safeMode.Configured | Should -BeFalse
        # The distinction that matters: unconfigured is NOT 'off'. An explicit off wins a
        # policy conflict; an absent rule does not.
        $safeMode.Mode | Should -BeNullOrEmpty
        $safeMode.RuleId | Should -Be '33ddedf1-c6e0-47cb-833e-de6133960387'
        $safeMode.RuleName | Should -Be 'Block rebooting machine in Safe Mode'

        # The '_perruleexclusions' definition is an exclusion list, not a rule.
        @($rows | Where-Object RuleSlug -match 'perruleexclusions') | Should -BeNullOrEmpty
    }

    It 'keeps exclusion groups out of the included list, so a carve-out is not inverted' {
        $row = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationPolicies\?' } -MockWith {
                @(New-AsrPolicy 'p2' 'ASR everyone except Developers' @() @((AllUsers), (Excl 'Devs'), (Incl 'Pilot')))
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationPolicies/p2/settings' } -MockWith {
                @([pscustomobject]@{ settingInstance = [pscustomobject]@{ groupSettingCollectionValue = @([pscustomobject]@{
                    children = @(New-AsrChild 'blockuseofcopiedorimpersonatedsystemtools' 'block') }) } })
            }
            Get-MsecIntuneAsrRule -Rule 'copied'
        }

        $row.AssignedAllUsers | Should -BeTrue
        $row.ExcludedGroup | Should -Be @('Devs')
        # 'exclusionGroupAssignmentTarget' also matches '*groupAssignmentTarget', and switch runs
        # every matching branch - without a break, 'Devs' appears in both lists and the policy
        # reads as applying to everyone.
        $row.AssignedGroup | Should -Be @('Pilot')
        $row.AssignedGroup | Should -Not -Contain 'Devs'
    }

    It 'calls two modes a deliberate ring when the policies exclude each other, not a conflict' {
        $rows = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationPolicies\?' } -MockWith {
                @(
                    New-AsrPolicy 'd1' 'ASR Developers'                @() @(Incl 'Devs')
                    New-AsrPolicy 'd2' 'ASR everyone except Developers' @() @((AllUsers), (Excl 'Devs'))
                )
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationPolicies/d1/settings' } -MockWith {
                @([pscustomobject]@{ settingInstance = [pscustomobject]@{ groupSettingCollectionValue = @([pscustomobject]@{
                    children = @(New-AsrChild 'blockexecutablefilesrunningunlesstheymeetprevalenceagetrustedlistcriterion' 'audit') }) } })
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationPolicies/d2/settings' } -MockWith {
                @([pscustomobject]@{ settingInstance = [pscustomobject]@{ groupSettingCollectionValue = @([pscustomobject]@{
                    children = @(New-AsrChild 'blockexecutablefilesrunningunlesstheymeetprevalenceagetrustedlistcriterion' 'block') }) } })
            }
            , @(Get-MsecIntuneAsrRule -Rule 'prevalence')
        }

        @($rows).Count | Should -Be 2
        @($rows | ForEach-Object Mode | Sort-Object) | Should -Be @('audit', 'block')
        $rows | ForEach-Object { $_.ModesDiffer | Should -BeTrue }
        # The carve-out is the whole design. Calling it a conflict sends someone to break it.
        $rows | ForEach-Object { $_.Conflicting | Should -BeFalse }
        $rows | ForEach-Object { $_.PolicyCount | Should -Be 2 }
    }

    It 'calls two modes a conflict when no policy carves the other out' {
        $rows = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationPolicies\?' } -MockWith {
                @(
                    New-AsrPolicy 'c1' 'Baseline' @() @(Incl 'Everyone')
                    New-AsrPolicy 'c2' 'Mathias test' @() @(Incl 'Testers')
                )
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationPolicies/c1/settings' } -MockWith {
                @([pscustomobject]@{ settingInstance = [pscustomobject]@{ groupSettingCollectionValue = @([pscustomobject]@{
                    children = @(New-AsrChild 'blockuseofcopiedorimpersonatedsystemtools' 'audit') }) } })
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationPolicies/c2/settings' } -MockWith {
                @([pscustomobject]@{ settingInstance = [pscustomobject]@{ groupSettingCollectionValue = @([pscustomobject]@{
                    children = @(New-AsrChild 'blockuseofcopiedorimpersonatedsystemtools' 'block') }) } })
            }
            $w = @()
            $r = @(Get-MsecIntuneAsrRule -Rule 'copied' -WarningVariable w -WarningAction SilentlyContinue)
            [pscustomobject]@{ Rows = $r; Warnings = "$($w -join ' ')" }
        }

        $rows.Rows | ForEach-Object { $_.Conflicting | Should -BeTrue }
        $rows.Warnings | Should -Match 'do NOT carve each other out'
    }

    It 'ignores Device Control settings that share the ASR template family' {
        $rows = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationPolicies\?' } -MockWith {
                @(New-AsrPolicy 'u1' 'USB Device Control' @() @(Incl 'All'))
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationPolicies/u1/settings' } -MockWith {
                @([pscustomobject]@{ settingInstance = [pscustomobject]@{ groupSettingCollectionValue = @([pscustomobject]@{
                    children = @(
                        # A real Device Control setting id. Sliced at the ASR prefix length this
                        # produced a rule called 'uleid}_ruledata'.
                        [pscustomobject]@{
                            settingDefinitionId = 'device_vendor_msft_defender_configuration_devicecontrol_policyrules_{ruleid}_ruledata'
                            choiceSettingValue  = [pscustomobject]@{ value = 'something' }
                        }
                    ) }) } })
            }
            , @(Get-MsecIntuneAsrRule)
        }

        @($rows | Where-Object Configured) | Should -BeNullOrEmpty
        @($rows | Where-Object { $_.RuleSlug -match 'ruledata|ruleid' }) | Should -BeNullOrEmpty
        # The catalogue rules are all still reported as unconfigured.
        @($rows).Count | Should -Be 3
    }

    It 'finds a per-rule exclusion NESTED under the rule, which is where Intune actually puts it' {
        # THIS FIXTURE IS THE REAL SHAPE. The first version of this test put the exclusion
        # beside the rule as a sibling - which is what the setting id
        # '<rule>_perruleexclusions' suggests - and the command was written to match. Both were
        # wrong: Intune hangs the list off the rule's own choiceSettingValue, one level deeper.
        # The test passed, the command reported no exclusions, and a live policy carrying a Git
        # exclusion read as having none. Fixture corrected from production data.
        $row = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationPolicies\?' } -MockWith {
                @(New-AsrPolicy 'e1' 'ASR with exclusions' @() @(Incl 'All'))
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationPolicies/e1/settings' } -MockWith {
                $slug = 'blockuseofcopiedorimpersonatedsystemtools'
                $base = 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules'
                @([pscustomobject]@{ settingInstance = [pscustomobject]@{ groupSettingCollectionValue = @([pscustomobject]@{
                    children = @(
                        [pscustomobject]@{
                            settingDefinitionId = "${base}_$slug"
                            choiceSettingValue  = [pscustomobject]@{
                                value    = "${base}_${slug}_block"
                                children = @(
                                    [pscustomobject]@{
                                        settingDefinitionId          = "${base}_${slug}_perruleexclusions"
                                        simpleSettingCollectionValue = @(
                                            [pscustomobject]@{ value = 'C:\Users\*\AppData\Local\Programs\Git\*' }
                                            [pscustomobject]@{ value = 'C:\Tools\build.exe' }
                                        )
                                    }
                                )
                            }
                        }
                    ) }) } })
            }
            Get-MsecIntuneAsrRule -Rule 'copied'
        }

        $row.Mode | Should -Be 'block'
        $row.PerRuleExclusion | Should -Contain 'C:\Users\*\AppData\Local\Programs\Git\*'
        $row.PerRuleExclusion | Should -Contain 'C:\Tools\build.exe'
        @($row.PerRuleExclusion).Count | Should -Be 2
    }

    It 'also finds an exclusion placed as a sibling, so a differing shape is not missed' {
        $row = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationPolicies\?' } -MockWith {
                @(New-AsrPolicy 'e2' 'Sibling shape' @() @(Incl 'All'))
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationPolicies/e2/settings' } -MockWith {
                @([pscustomobject]@{ settingInstance = [pscustomobject]@{ groupSettingCollectionValue = @([pscustomobject]@{
                    children = @(
                        New-AsrChild 'blockuseofcopiedorimpersonatedsystemtools' 'block'
                        [pscustomobject]@{
                            settingDefinitionId          = 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockuseofcopiedorimpersonatedsystemtools_perruleexclusions'
                            simpleSettingCollectionValue = @([pscustomobject]@{ value = 'C:\Legacy\shape.exe' })
                        }
                    ) }) } })
            }
            Get-MsecIntuneAsrRule -Rule 'copied'
        }

        $row.PerRuleExclusion | Should -Contain 'C:\Legacy\shape.exe'
    }

    It 'does not attribute one rule exclusions belonging to another' {
        $rows = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationPolicies\?' } -MockWith {
                @(New-AsrPolicy 'e3' 'Two rules one exclusion' @() @(Incl 'All'))
            }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationPolicies/e3/settings' } -MockWith {
                $base = 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules'
                @([pscustomobject]@{ settingInstance = [pscustomobject]@{ groupSettingCollectionValue = @([pscustomobject]@{
                    children = @(
                        # Only the Safe Mode rule carries an exclusion.
                        [pscustomobject]@{
                            settingDefinitionId = "${base}_blockrebootingmachineinsafemode"
                            choiceSettingValue  = [pscustomobject]@{
                                value    = "${base}_blockrebootingmachineinsafemode_block"
                                children = @([pscustomobject]@{
                                    settingDefinitionId          = "${base}_blockrebootingmachineinsafemode_perruleexclusions"
                                    simpleSettingCollectionValue = @([pscustomobject]@{ value = 'C:\Only\SafeMode.exe' })
                                })
                            }
                        }
                        New-AsrChild 'blockuseofcopiedorimpersonatedsystemtools' 'block'
                    ) }) } })
            }
            , @(Get-MsecIntuneAsrRule)
        }

        $safe   = $rows | Where-Object RuleSlug -eq 'blockrebootingmachineinsafemode'
        $copied = $rows | Where-Object RuleSlug -eq 'blockuseofcopiedorimpersonatedsystemtools'
        $safe.PerRuleExclusion | Should -Contain 'C:\Only\SafeMode.exe'
        # A subtree walk that started too high would hand this rule its neighbour's exclusion,
        # which reads as a hole that does not exist.
        @($copied.PerRuleExclusion).Count | Should -Be 0
    }

    It 'warns that rules reported unconfigured may be set on surfaces it does not read' {
        $warning = InModuleScope Msec {
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'configurationPolicies\?' } -MockWith { @() }
            Mock Invoke-MsecGraphRequest -ParameterFilter { $Path -match 'deviceManagement/intents' } -MockWith {
                @([pscustomobject]@{ id = 'i1'; displayName = 'Endpoint security baseline' })
            }
            $w = @()
            Get-MsecIntuneAsrRule -WarningVariable w -WarningAction SilentlyContinue | Out-Null
            "$($w -join ' ')"
        }

        $warning | Should -Match 'Endpoint security baseline'
        $warning | Should -Match 'Group Policy'
    }
}