tests/Test-Assessment.41119.ps1

<#
.SYNOPSIS
    Local modification of Microsoft Defender Antivirus policy is blocked.
 
.DESCRIPTION
    Checks whether an assigned Intune Settings Catalog policy sets the Defender
    DisableLocalAdminMerge control to Yes (..._1) so that only management-defined
    Defender Antivirus settings (such as exclusions) are used in the effective policy.
 
.NOTES
    Test ID: 41119
    Workshop Task ID: SECOPS-119
    Category: Endpoint threat protection
    Pillar: SecOps
    Required Module: Microsoft.Graph.Authentication
    Required Connection: Microsoft Graph
    Required Permission: DeviceManagementConfiguration.Read.All
#>


function Test-Assessment-41119 {
    [ZtTest(
        Category = 'Endpoint threat protection',
        CompatibleLicense = ('INTUNE_A'),
        ImplementationCost = 'Low',
        Pillar = 'SecOps',
        RiskLevel = 'High',
        Service = ('Graph'),
        SfiPillar = 'Monitor and detect cyberthreats',
        TenantType = ('Workforce'),
        TestId = 41119,
        Title = 'Local modification of Microsoft Defender Antivirus policy is blocked',
        UserImpact = 'Low'
    )]
    [CmdletBinding()]
    param()

    #region Helper Functions

    # Recursively collects every setting instance in a policy's settings tree, including
    # instances nested inside choiceSettingValue.children, choiceSettingCollectionValue,
    # simpleSettingCollectionValue, groupSettingCollectionValue and groupSettingValue.children paths.
    function Get-AllSettingInstances {
        param([array]$SettingInstances)
        $result = [System.Collections.Generic.List[object]]::new()
        foreach ($si in $SettingInstances) {
            if ($null -eq $si) { continue }
            $result.Add($si)
            if ($si.choiceSettingValue -and $si.choiceSettingValue.children) {
                $result.AddRange([object[]](Get-AllSettingInstances -SettingInstances @($si.choiceSettingValue.children)))
            }
            if ($si.choiceSettingCollectionValue) {
                foreach ($csv in $si.choiceSettingCollectionValue) {
                    if ($csv.children) {
                        $result.AddRange([object[]](Get-AllSettingInstances -SettingInstances @($csv.children)))
                    }
                }
            }
            if ($si.simpleSettingCollectionValue) {
                foreach ($ssv in $si.simpleSettingCollectionValue) {
                    if ($ssv.children) {
                        $result.AddRange([object[]](Get-AllSettingInstances -SettingInstances @($ssv.children)))
                    }
                }
            }
            if ($si.groupSettingCollectionValue) {
                foreach ($gsv in $si.groupSettingCollectionValue) {
                    if ($gsv.children) {
                        $result.AddRange([object[]](Get-AllSettingInstances -SettingInstances @($gsv.children)))
                    }
                }
            }
            if ($si.groupSettingValue -and $si.groupSettingValue.children) {
                $result.AddRange([object[]](Get-AllSettingInstances -SettingInstances @($si.groupSettingValue.children)))
            }
        }
        return $result
    }

    #endregion Helper Functions

    #region Data Collection

    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose

    $activity = 'Checking Defender local admin merge configuration'
    $title = 'Local modification of Microsoft Defender Antivirus policy is blocked'
    $controlId = 'device_vendor_msft_defender_configuration_disablelocaladminmerge'

    # Maps assignment target @odata.type to a friendly type label shown in the report.
    # Only the target type is displayed (no group name/id), so the check stays within
    # DeviceManagementConfiguration.Read.All (name resolution would need Group.Read.All).
    $assignmentTargetMap = @{
        '#microsoft.graph.allDevicesAssignmentTarget'                     = 'allDevices'
        '#microsoft.graph.allLicensedUsersAssignmentTarget'               = 'allLicensedUsers'
        '#microsoft.graph.groupAssignmentTarget'                          = 'groupAssignment'
        '#microsoft.graph.exclusionGroupAssignmentTarget'                 = 'exclusionGroupAssignment'
        '#microsoft.graph.configurationManagerCollectionAssignmentTarget' = 'configurationManagerCollection'
    }

    # Q1: List Settings Catalog configuration policies (beta), following @odata.nextLink automatically.
    # $expand=assignments brings the assignment state inline (isAssigned is not returned by default).
    $rootError = $null
    $settingsCatalogPolicies = @()
    try {
        Write-ZtProgress -Activity $activity -Status 'Getting Settings Catalog policies'
        $settingsCatalogPolicies = @(Invoke-ZtGraphRequest -RelativeUri 'deviceManagement/configurationPolicies?$expand=assignments' -ApiVersion beta -ErrorAction Stop)
        $settingsCatalogPolicies = @($settingsCatalogPolicies | Where-Object { $_.technologies -match '(?i)\bmdm\b' })
        Write-PSFMessage "Found $($settingsCatalogPolicies.Count) Settings Catalog policies" -Level Verbose
    }
    catch {
        $rootError = $_
        Write-PSFMessage "Failed to query Settings Catalog policies: $_" -Tag Test -Level Warning
    }

    # A failure of the root list query (or any of its pages) is unrecoverable: return Investigate.
    if ($rootError) {
        $httpStatus = Get-ZtHttpStatusCode -ErrorRecord $rootError
        Write-PSFMessage "Failed to query Settings Catalog policies (HTTP $httpStatus): $rootError" -Tag Test -Level Warning
        $msg = if ($httpStatus -in @(401, 403)) {
            '⚠️ **DeviceManagementConfiguration.Read.All** permission is required to read Intune configuration policies. Verify the permission is consented and the assessment identity has an Intune read role, then re-run the assessment.'
        } elseif ($httpStatus -eq 404) {
            '⚠️ The Intune configuration policies endpoint returned 404. Verify that Intune is provisioned in this tenant, then re-run the assessment.'
        } else {
            '⚠️ Microsoft Graph returned an unexpected error while querying Intune Settings Catalog configuration policies. Re-run after 5–10 minutes; file a support ticket if this persists.'
        }
        $params = @{
            TestId       = '41119'
            Title        = $title
            Status       = $false
            Result       = $msg
            CustomStatus = 'Investigate'
        }
        Add-ZtTestResultDetail @params
        return
    }

    $evaluationResults = @()
    foreach ($policy in $settingsCatalogPolicies) {
        $templateFamily = [string]$policy.templateReference.templateFamily
        if ([string]::IsNullOrWhiteSpace($templateFamily)) { $templateFamily = 'none' }

        # PolicySummaryBlade deep link requires technology, templateId and platformName segments.
        $technologies = @($policy.technologies) -join ','
        $templateId = [string]$policy.templateReference.templateId
        $platforms = @($policy.platforms) -join ','

        # Assignment comes from the $expand=assignments projection on the list query.
        # Classify as Assigned / Unassigned / Unknown so conflicting or absent/unusable signals
        # surface as Investigate rather than a false Pass/Fail (isAssigned is not returned by default).
        $assignmentsProp = $policy.PSObject.Properties['assignments']
        # An absent property or a null value is unusable evidence (Unknown); a real (possibly empty)
        # collection is usable — an empty array is a definite Unassigned.
        $assignmentsUsable = ($null -ne $assignmentsProp) -and ($null -ne $assignmentsProp.Value)
        $assignmentCount = @($policy.assignments | Where-Object { $null -ne $_ }).Count
        $isAssigned = $null
        if ($null -ne $policy.isAssigned) { $isAssigned = [bool]$policy.isAssigned }
        if (-not $assignmentsUsable) {
            $assignmentState = 'Unknown'
        }
        elseif ($null -ne $isAssigned -and $isAssigned -ne ($assignmentCount -ge 1)) {
            # isAssigned flag conflicts with the inline assignments array.
            $assignmentState = 'Unknown'
        }
        elseif ($assignmentCount -ge 1) {
            $assignmentState = 'Assigned'
        }
        else {
            $assignmentState = 'Unassigned'
        }
        $assignedText = switch ($assignmentState) { 'Assigned' { 'Yes' } 'Unassigned' { 'No' } default { 'Unknown' } }
        # Show target type only; no groupId->name lookup (no Group.Read.All) and no ids in output.
        $assignmentTargets = @($policy.assignments | Where-Object { $null -ne $_ } | ForEach-Object {
            $targetType = [string]$_.target.'@odata.type'
            if ($assignmentTargetMap.ContainsKey($targetType)) { $assignmentTargetMap[$targetType] }
            elseif (-not [string]::IsNullOrWhiteSpace($targetType)) { ($targetType -replace '^#microsoft\.graph\.', '') -replace 'AssignmentTarget$', '' }
            else { 'unknown' }
        })
        $assignmentTargetsText = if ($assignmentTargets.Count -gt 0) { ($assignmentTargets | Select-Object -Unique) -join ', ' } else { 'None' }

        # Read the policy settings (beta, auto-paged). An unreadable settings surface classifies
        # this policy as Investigate but does not stop evaluation of the remaining policies.
        try {
            Write-ZtProgress -Activity $activity -Status "Reading Settings Catalog policy $($policy.name)"
            $settings = @(Invoke-ZtGraphRequest -RelativeUri "deviceManagement/configurationPolicies/$($policy.id)/settings" -ApiVersion beta -ErrorAction Stop)
        }
        catch {
            $statusCode = Get-ZtHttpStatusCode -ErrorRecord $_
            $statusText = if ($null -ne $statusCode) { "HTTP $statusCode" } else { 'a network or timeout error' }
            Write-PSFMessage "Failed to read settings for policy '$($policy.name)': $_" -Tag Test -Level Warning
            $evaluationResults += [PSCustomObject]@{
                PolicyName          = $policy.name
                PolicyId            = $policy.id
                TemplateFamily      = $templateFamily
                Technologies        = $technologies
                TemplateId          = $templateId
                Platforms           = $platforms
                Assigned            = $assignedText
                AssignmentTargets   = $assignmentTargetsText
                SettingDefinitionId = 'N/A'
                RawSettingValue     = 'N/A'
                NormalizedState     = 'Unknown'
                Details             = "The policy settings could not be read ($statusText)."
                Status              = 'Investigate'
            }
            continue
        }

        $malformedSettings = @($settings | Where-Object {
            $null -eq $_ -or
            $null -eq $_.PSObject.Properties['settingInstance'] -or
            $null -eq $_.PSObject.Properties['settingInstance'].Value
        })
        if ($malformedSettings.Count -gt 0) {
            Write-PSFMessage "The settings response for policy '$($policy.name)' did not contain usable settingInstance data." -Tag Test -Level Warning
            $evaluationResults += [PSCustomObject]@{
                PolicyName          = $policy.name
                PolicyId            = $policy.id
                TemplateFamily      = $templateFamily
                Technologies        = $technologies
                TemplateId          = $templateId
                Platforms           = $platforms
                Assigned            = $assignedText
                AssignmentTargets   = $assignmentTargetsText
                SettingDefinitionId = 'N/A'
                RawSettingValue     = 'N/A'
                NormalizedState     = 'Unknown'
                Details             = 'The policy settings response did not contain usable settingInstance data.'
                Status              = 'Investigate'
            }
            continue
        }

        # Identify the control by exact, case-insensitive settingDefinitionId equality.
        # Evaluate every matching instance (Graph can nest duplicates) and roll up within the policy.
        $allInstances = @(Get-AllSettingInstances -SettingInstances @($settings.settingInstance))
        $matchingInstances = @($allInstances | Where-Object {
            [string]$_.settingDefinitionId -ieq $controlId
        })

        if ($matchingInstances.Count -eq 0) {
            # A policy without the control instance is N/A and is excluded from the roll-up.
            $evaluationResults += [PSCustomObject]@{
                PolicyName          = $policy.name
                PolicyId            = $policy.id
                TemplateFamily      = $templateFamily
                Technologies        = $technologies
                TemplateId          = $templateId
                Platforms           = $platforms
                Assigned            = $assignedText
                AssignmentTargets   = $assignmentTargetsText
                SettingDefinitionId = 'N/A'
                RawSettingValue     = 'N/A'
                NormalizedState     = 'N/A'
                Details             = 'The policy does not contain a disable local admin merge setting.'
                Status              = 'N/A'
            }
            continue
        }

        # Classify each matching instance, then roll up within the policy: Fail > Investigate > Pass.
        $rawValues = [System.Collections.Generic.List[string]]::new()
        $normalizedStates = [System.Collections.Generic.List[string]]::new()
        $instanceStatuses = [System.Collections.Generic.List[string]]::new()
        foreach ($mi in $matchingInstances) {
            $v = [string]$mi.choiceSettingValue.value
            $rawValues.Add($(if ([string]::IsNullOrWhiteSpace($v)) { 'N/A' } else { $v }))
            $ns = if ($v -match '(?i)_disablelocaladminmerge_1$') { 'Enabled' }
                elseif ($v -match '(?i)_disablelocaladminmerge_0$') { 'Disabled' }
                else { 'Unknown' }
            $normalizedStates.Add($ns)
            if ($ns -eq 'Unknown') { $instanceStatuses.Add('Investigate') }
            elseif ($ns -eq 'Disabled') { $instanceStatuses.Add('Fail') }
            elseif ($assignmentState -eq 'Unknown') { $instanceStatuses.Add('Investigate') }
            elseif ($assignmentState -eq 'Assigned') { $instanceStatuses.Add('Pass') }
            else { $instanceStatuses.Add('Fail') }
        }

        $rawValue = (@($rawValues) | Select-Object -Unique) -join ', '
        $normalizedState = (@($normalizedStates) | Select-Object -Unique) -join ', '
        $status = if ($instanceStatuses -contains 'Fail') { 'Fail' }
            elseif ($instanceStatuses -contains 'Investigate') { 'Investigate' }
            else { 'Pass' }

        $details = switch ($status) {
            'Pass' { 'Disable local admin merge is set to yes on an assigned policy.' }
            'Investigate' {
                if ($normalizedStates -contains 'Unknown') { 'A disable local admin merge value could not be interpreted.' }
                else { 'The policy assignment state could not be determined from the returned data.' }
            }
            default {
                if ($normalizedStates -contains 'Disabled') { 'Disable local admin merge is set to no.' }
                else { 'Disable local admin merge is set to yes but the policy is not assigned.' }
            }
        }

        $evaluationResults += [PSCustomObject]@{
            PolicyName          = $policy.name
            PolicyId            = $policy.id
            TemplateFamily      = $templateFamily
            Technologies        = $technologies
            TemplateId          = $templateId
            Platforms           = $platforms
            Assigned            = $assignedText
            AssignmentTargets   = $assignmentTargetsText
            SettingDefinitionId = $matchingInstances[0].settingDefinitionId
            RawSettingValue     = $rawValue
            NormalizedState     = $normalizedState
            Details             = $details
            Status              = $status
        }
    }

    #endregion Data Collection

    #region Assessment Logic

    # Only policies that actually carry the control are evaluable; N/A policies are excluded from the roll-up.
    $evaluableResults = @($evaluationResults | Where-Object { $_.Status -ne 'N/A' })
    $passed = $false
    $customStatus = $null

    if ($evaluableResults.Count -eq 0) {
        # No matching control instance remained after N/A exclusions (includes legacy-only enforcement).
        $customStatus = 'Investigate'
        $testResultMarkdown = "⚠️ The **DisableLocalAdminMerge** state could not be determined on the evaluated Intune surface — no matching control was found (including legacy-only enforcement) or the evaluable set was empty. Verify **DeviceManagementConfiguration.Read.All**, Intune RBAC, and the policy in the Intune portal.`n`n%TestResult%"
    }
    else {
        # Roll-up precedence: Fail > Investigate > Pass.
        $failResults = @($evaluableResults | Where-Object { $_.Status -eq 'Fail' })
        $investigateResults = @($evaluableResults | Where-Object { $_.Status -eq 'Investigate' })

        if ($failResults.Count -gt 0) {
            $testResultMarkdown = "❌ A retrieved Intune policy sets **DisableLocalAdminMerge** to No (**..._0**), or enables it only in an unassigned policy.`n`n%TestResult%"
        }
        elseif ($investigateResults.Count -gt 0) {
            $customStatus = 'Investigate'
            $testResultMarkdown = "⚠️ The **DisableLocalAdminMerge** state could not be determined on the evaluated Intune surface — a value or assignment state was uninterpretable, or Microsoft Graph could not be read. Verify **DeviceManagementConfiguration.Read.All**, Intune RBAC, and the policy in the Intune portal.`n`n%TestResult%"
        }
        else {
            $passed = $true
            $testResultMarkdown = "✅ Local modification of Microsoft Defender Antivirus policy is blocked — an assigned Intune Settings Catalog policy sets **DisableLocalAdminMerge** to Yes.`n`n%TestResult%"
        }
    }

    #endregion Assessment Logic

    #region Report Generation

    $portalUrl = 'https://intune.microsoft.com/#view/Microsoft_Intune_DeviceSettings/DevicesMenu/~/configuration'

    # Show every evaluated policy so the report is complete, even when policies are N/A for this control.
    $statusIcon = @{
        'Pass'        = '✅ Pass'
        'Fail'        = '❌ Fail'
        'Investigate' = '⚠️ Investigate'
        'N/A'         = 'N/A'
    }
    $totalCount = $evaluationResults.Count
    $isTruncated = $totalCount -gt 10
    $tableRows = @($evaluationResults | Select-Object -First 10 | ForEach-Object {
        $policyName = (Get-SafeMarkdown -Text $_.PolicyName) -replace '\|', '\\|'
        $encodedTechnologies = ([string]$_.Technologies) -replace ',', '%2C'
        $policyLink = "https://intune.microsoft.com/#view/Microsoft_Intune_Workflows/PolicySummaryBlade/policyId/$($_.PolicyId)/technology/$encodedTechnologies/templateId/$($_.TemplateId)/platformName/$($_.Platforms)"
        $assignmentTargets = (Get-SafeMarkdown -Text $_.AssignmentTargets) -replace '\|', '\\|'
        $settingDefId = (Get-SafeMarkdown -Text $_.SettingDefinitionId) -replace '\|', '\\|'
        $rawValue = (Get-SafeMarkdown -Text $_.RawSettingValue) -replace '\|', '\\|'
        $detailsText = (Get-SafeMarkdown -Text $_.Details) -replace '\|', '\\|'
        $displayStatus = $statusIcon[$_.Status]
        if (-not $displayStatus) { $displayStatus = $_.Status }
        "| [$policyName]($policyLink) | $($_.TemplateFamily) | $($_.Assigned) | $assignmentTargets | $settingDefId | $rawValue | $($_.NormalizedState) | $detailsText | $displayStatus |"
    })

    if ($isTruncated) {
        $tableRows += "| ... $($totalCount - 10) omitted; $totalCount total ... | ... | ... | ... | ... | ... | ... | ... | ... |"
    }

    if ($tableRows.Count -gt 0) {
        $countLine = if ($isTruncated) {
            "Showing first 10 of $totalCount policies"
        } else {
            "Total policies: $totalCount"
        }
        $mdInfo = @"
 
## [Intune configuration profiles]($portalUrl)
 
$countLine
 
| Policy name | Template family | Assigned | Assignment targets | Setting definition ID | Raw setting value | Normalized state | Details | Status |
| :---------- | :-------------- | :------- | :----------------- | :-------------------- | :---------------- | :--------------- | :------ | :----- |
$($tableRows -join "`n")
"@

        $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo
    }

    # Safety net: strip any unresolved placeholder so it never surfaces in the report.
    $testResultMarkdown = $testResultMarkdown -replace '\s*%TestResult%', ''

    $params = @{
        TestId = '41119'
        Title  = $title
        Status = $passed
        Result = $testResultMarkdown
    }
    if ($null -ne $customStatus) {
        $params.CustomStatus = $customStatus
    }
    Add-ZtTestResultDetail @params

    #endregion Report Generation
}