tests/Test-Assessment.41019.ps1

<#
.SYNOPSIS
    Checks that compromised identities surfaced by Microsoft Defender for Identity alerts have been remediated.
 
.NOTES
    Test ID: 41019
    Workshop Task: SECOPS-019
    Pillar: SecOps
    Category: Identity threat protection
    Required permission: SecurityAlert.Read.All
#>


function Test-Assessment-41019 {

    [ZtTest(
        Category           = 'Identity threat protection',
        CompatibleLicense  = ('ATA'),
        ImplementationCost = 'Low',
        Pillar             = 'SecOps',
        RiskLevel          = 'High',
        Service            = ('Graph'),
        SfiPillar          = 'Accelerate response and remediation',
        TenantType         = ('Workforce'),
        TestId             = 41019,
        Title              = 'Compromised identities surfaced by Microsoft Defender for Identity have been remediated',
        UserImpact         = 'Medium'
    )]
    [CmdletBinding()]
    param()

    #region Data Collection
    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
    $activity = 'Checking Microsoft Defender for Identity compromised identity remediation'
    Write-ZtProgress -Activity $activity -Status 'Querying open MDI alerts'

    # Q1: List open MDI alerts; the evidence collection is returned inline per alert.
    # Compromised-user evidence and remediation state are evaluated client-side.
    # Prefer: include-unknown-enum-members is required so that the six gated remediationStatus
    # values (active, pendingApproval, declined, unremediated, running, partiallyRemediated)
    # are returned as their real strings rather than collapsing to unknownFutureValue.
    $openAlerts = $null
    try {
        $openAlerts = Invoke-ZtGraphRequest -RelativeUri 'security/alerts_v2' -Filter "serviceSource eq 'microsoftDefenderForIdentity' and status ne 'resolved'" -ApiVersion beta -Headers @{ Prefer = 'include-unknown-enum-members' } -ErrorAction Stop
    }
    catch {
        $httpStatus = Get-ZtHttpStatusCode -ErrorRecord $_
        if ($httpStatus -in @(401, 403)) {
            $params = @{
                TestId       = '41019'
                Title        = 'Compromised identities surfaced by Microsoft Defender for Identity have been remediated'
                Status       = $false
                Result       = '⚠️ Insufficient Graph permission for SecurityAlert.Read.All; the assessment runtime cannot read MDI alerts.'
                CustomStatus = 'Investigate'
            }
            Add-ZtTestResultDetail @params
            return
        }
        $params = @{
            TestId       = '41019'
            Title        = 'Compromised identities surfaced by Microsoft Defender for Identity have been remediated'
            Status       = $false
            Result       = '⚠️ Transient Microsoft Graph error or unexpected response shape; re-run after 5-10 minutes.'
            CustomStatus = 'Investigate'
        }
        Add-ZtTestResultDetail @params
        return
    }

    # Q2: When Q1 returns nothing, probe whether MDI has any alerts at all to distinguish
    # "no open alerts" from "MDI is not deployed".
    $mdiPresenceAlerts = $null
    if ($null -eq $openAlerts -or @($openAlerts).Count -eq 0) {
        Write-ZtProgress -Activity $activity -Status 'Probing MDI telemetry presence'
        try {
            $mdiPresenceAlerts = Invoke-ZtGraphRequest -RelativeUri 'security/alerts_v2' -Filter "serviceSource eq 'microsoftDefenderForIdentity'" -Top 1 -ApiVersion beta -ErrorAction Stop
        }
        catch {
            $httpStatus = Get-ZtHttpStatusCode -ErrorRecord $_
            if ($httpStatus -in @(401, 403)) {
                $params = @{
                    TestId       = '41019'
                    Title        = 'Compromised identities surfaced by Microsoft Defender for Identity have been remediated'
                    Status       = $false
                    Result       = '⚠️ Insufficient Graph permission for SecurityAlert.Read.All; the assessment runtime cannot read MDI alerts.'
                    CustomStatus = 'Investigate'
                }
                Add-ZtTestResultDetail @params
                return
            }
            $params = @{
                TestId       = '41019'
                Title        = 'Compromised identities surfaced by Microsoft Defender for Identity have been remediated'
                Status       = $false
                Result       = '⚠️ Transient Microsoft Graph error or unexpected response shape; re-run after 5-10 minutes.'
                CustomStatus = 'Investigate'
            }
            Add-ZtTestResultDetail @params
            return
        }
    }
    #endregion Data Collection

    #region Assessment Logic
    # Unfiltered MDI probe also returned nothing: MDI is not deployed in this tenant.
    if (($null -eq $openAlerts -or @($openAlerts).Count -eq 0) -and
        ($null -eq $mdiPresenceAlerts -or @($mdiPresenceAlerts).Count -eq 0)) {
        Add-ZtTestResultDetail -SkippedBecause NotApplicable -Result 'No Microsoft Defender for Identity alerts are open in this tenant.'
        return
    }

    $containedStatuses    = @('remediated', 'prevented', 'blocked')
    # unknownFutureValue is included here as a defensive fallback: if the Prefer header is
    # somehow dropped or a future API version adds new gated values, treat them as Investigate
    # rather than silently promoting them to Fail.
    $inProgressStatuses   = @('active', 'running', 'pendingApproval', 'partiallyRemediated', 'unknownFutureValue')

    # One row per compromised userEvidence — an alert can implicate multiple accounts.
    $compromisedRows = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($alert in @($openAlerts)) {
        $compromisedEvidenceList = @($alert.evidence | Where-Object {
            $_.'@odata.type' -eq '#microsoft.graph.security.userEvidence' -and
            $_.roles -contains 'compromised'
        })

        foreach ($userEvidence in $compromisedEvidenceList) {
            $rawRemediationStatus = $userEvidence.remediationStatus
            $isAbsent             = [string]::IsNullOrEmpty($rawRemediationStatus)

            # Absent means the product hasn't attached a remediation object yet (e.g. a freshly-raised
            # alert). "No data" is not a confirmed gap, so treat as Investigate rather than Fail.
            # Only positive not-remediated signals (none/notFound/declined/unremediated) → Fail.
            $rowVerdict = if ($isAbsent) {
                'Investigate'
            } elseif ($rawRemediationStatus -in $containedStatuses) {
                'Pass'
            } elseif ($rawRemediationStatus -in $inProgressStatuses) {
                'Investigate'
            } else {
                # none, notFound, declined, unremediated → confirmed not remediated
                'Fail'
            }

            $compromisedRows.Add([PSCustomObject]@{
                AlertTitle        = $alert.title
                AlertSeverity     = $alert.severity
                AlertStatus       = $alert.status
                AssignedTo        = $alert.assignedTo
                FirstActivity     = $alert.firstActivityDateTime
                IncidentId        = $alert.incidentId
                IncidentWebUrl    = $alert.incidentWebUrl
                UserAccount       = $userEvidence.userAccount
                RemediationStatus = if ($isAbsent) { 'absent' } else { $rawRemediationStatus }
                RowVerdict        = $rowVerdict
            })
        }
    }

    # No compromised-user evidence found across all open MDI alerts.
    if ($compromisedRows.Count -eq 0) {
        $params = @{
            TestId = '41019'
            Title  = 'Compromised identities surfaced by Microsoft Defender for Identity have been remediated'
            Status = $true
            Result = '✅ No compromised identities surfaced by Microsoft Defender for Identity are awaiting remediation.'
        }
        Add-ZtTestResultDetail @params
        return
    }

    # Aggregate verdict: Fail > Investigate > Pass.
    $allVerdicts  = @($compromisedRows | Select-Object -ExpandProperty RowVerdict)
    $passed       = $true
    $customStatus = $null

    if ($allVerdicts -contains 'Fail') {
        $passed = $false
        $testResultMarkdown = "❌ One or more identities flagged as compromised by Microsoft Defender for Identity have not been remediated.`n`n%TestResult%"
    } elseif ($allVerdicts -contains 'Investigate') {
        $passed       = $false
        $customStatus = 'Investigate'
        $testResultMarkdown = "⚠️ Remediation is in progress for the flagged compromised identities; confirm that it completes.`n`n%TestResult%"
    } else {
        $testResultMarkdown = "✅ No compromised identities surfaced by Microsoft Defender for Identity are awaiting remediation.`n`n%TestResult%"
    }
    #endregion Assessment Logic

    #region Report Generation
    $alertsPortalUrl = 'https://security.microsoft.com/alerts'

    # Sort Fail rows first (F < I < P alphabetically), then by alert title within each verdict.
    $sortedRows  = @($compromisedRows | Sort-Object -Property RowVerdict, AlertTitle)
    $totalCount  = $sortedRows.Count
    $displayRows = @($sortedRows | Select-Object -First 10)
    $isTruncated = $totalCount -gt 10

    $anyNonPass     = @($compromisedRows | Where-Object { $_.RowVerdict -ne 'Pass' })
    $showPortalLink = $isTruncated -or $anyNonPass.Count -gt 0

    $preTableLines = ''
    if ($isTruncated) {
        $preTableLines += "Total compromised identities: $totalCount (showing first 10)`n`n"
    } else {
        $preTableLines += "Total compromised identities: $totalCount`n`n"
    }
    if ($showPortalLink) {
        $preTableLines += "[Defender XDR > Investigation & response > Alerts]($alertsPortalUrl)`n`n"
    }

    $tableRows = ''
    foreach ($row in $displayRows) {
        $alertTitle = Get-SafeMarkdown -Text $row.AlertTitle
        $userAccount = $row.UserAccount

        # Prefer UPN; fall back to domainName\accountName (down-level format matching MDI on-prem display).
        $userDisplay = if (-not [string]::IsNullOrEmpty($userAccount.userPrincipalName)) {
            Get-SafeMarkdown -Text $userAccount.userPrincipalName
        } else {
            Get-SafeMarkdown -Text "$($userAccount.domainName)\$($userAccount.accountName)"
        }

        $severity          = $row.AlertSeverity
        $alertStatus       = $row.AlertStatus
        $remediationStatus = switch ($row.RemediationStatus) {
            { $_ -in $containedStatuses }                        { "✅ $($row.RemediationStatus)" }
            { $_ -in $inProgressStatuses -or $_ -eq 'absent' }  { "⚠️ $($row.RemediationStatus)" }
            default                                              { "❌ $($row.RemediationStatus)" }
        }
        $assignedTo        = if ([string]::IsNullOrEmpty($row.AssignedTo)) { '—' } else { Get-SafeMarkdown -Text $row.AssignedTo }
        $firstActivity     = if ([string]::IsNullOrEmpty($row.FirstActivity)) { '—' } else { Get-FormattedDate -DateString $row.FirstActivity }

        $incidentCell = if (-not [string]::IsNullOrEmpty($row.IncidentWebUrl)) {
            "[Incident $($row.IncidentId)]($($row.IncidentWebUrl))"
        } elseif (-not [string]::IsNullOrEmpty($row.IncidentId)) {
            $row.IncidentId
        } else {
            '—'
        }

        $rowResult = switch ($row.RowVerdict) {
            'Pass'        { '✅ Pass' }
            'Investigate' { '⚠️ Investigate' }
            default       { '❌ Fail' }
        }

        $tableRows += "| $alertTitle | $userDisplay | $severity | $alertStatus | $remediationStatus | $assignedTo | $firstActivity | $incidentCell | $rowResult |`n"
    }

    if ($isTruncated) {
        $tableRows += "| ... | ... | ... | ... | ... | ... | ... | ... | ... |`n"
    }

    $mdInfo = @"
$preTableLines
| Alert title | Compromised user | Severity | Alert status | Remediation status | Assigned to | First activity | Incident | Result |
| :---------- | :--------------- | :------- | :----------- | :----------------- | :---------- | :------------- | :------- | :----- |
$tableRows
"@


    $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo
    #endregion Report Generation

    $params = @{
        TestId = '41019'
        Title  = 'Compromised identities surfaced by Microsoft Defender for Identity have been remediated'
        Status = $passed
        Result = $testResultMarkdown
    }
    if ($customStatus) {
        $params.CustomStatus = $customStatus
    }
    Add-ZtTestResultDetail @params
}