public/Set-MsecDefenderAlert.ps1

function Set-MsecDefenderAlert {
    <#
    .SYNOPSIS
        Resolve, classify or assign Defender XDR alerts. Runs as YOU - the app cannot do this.

    .DESCRIPTION
        Requires the delegated session from Connect-MsecAdmin and refuses the app session: every
        permission New-MsecApp consents is *.Read.All, so the certificate in Key Vault could not
        do this even if asked, and failing here with a sentence beats failing later with a 403
        that names nothing.

        ONE IDENTITY THROUGHOUT. Every call this command makes goes through that one delegated
        session. That is deliberate and was not always true: a -Comment switch existed briefly,
        routed to the Defender for Endpoint API on a separate Az-context token, which put two
        different user identities inside a single command - the alert could be resolved by one
        person and commented by another.

        THERE IS NO COMMENT HERE. Microsoft Graph has no writable comment on an alert -
        `comments` on alerts_v2 is read-only in v1.0 and beta alike, with no navigation property
        and no action. The Defender for Endpoint API does have one, but it only knows ENDPOINT
        alerts: measured on one tenant, 29 of 569, and none of the alerts anyone actually
        triaged. Covering five per cent of the fleet did not justify a second authentication
        path inside one command.

        Put the note on the incident instead - Set-MsecDefenderIncident -ResolvingComment, which
        Microsoft describes as explaining the resolution and the classification choice, and which
        works for every incident whatever its alerts came from.

        THERE IS NO CAP ON HOW MANY ALERTS IT WILL CHANGE. `Get-… | Set-…` works through
        everything the filter selected - on one tenant `Get-MsecDefenderAlert -Status new`
        returns 201 rows. What stands between you and that is ConfirmImpact 'High', so a bare
        call prompts per alert, and -WhatIf, which lists every id it would touch and changes
        nothing. Use -WhatIf first on any pipeline you have not run before; -Confirm:$false
        turns off the only remaining prompt.

        Ids are collected before the first write rather than acted on as they arrive, so
        duplicates in the pipeline are written once.

        IT RE-READS AFTER WRITING, AND WAITS FOR THE SERVICE TO SETTLE. The PATCH response is
        the service echoing the request; a separate GET is the service being asked what the
        alert now IS. But XDR is eventually consistent - measured live, an alert PATCHed at
        16:37:00 still read as unchanged immediately afterwards and was correct moments later -
        so the read-back polls briefly (about ten seconds at most) and stops as soon as the
        values match. A warning is raised only when a field is STILL wrong after the last read,
        which makes it worth acting on. Changed is $null when it could not be verified - an
        unverified write must never render as a confirmed one.

        DETERMINATION VALUES ARE NOT THE OBVIOUS ONES. From Graph's own $metadata:
        'notMalicious' and 'notEnoughDataToValidate', not 'clean' and 'insufficientData'.
        Microsoft's Update alert page still lists the retired names for this shared enum while
        the Update incident page and $metadata agree with the values here - so do not "fix"
        these from that page. Note too that the CSDL calls the first status member 'newAlert'
        while the wire value is 'new'; the wire value is what this takes.

    .PARAMETER Id
        Alert ids. Takes pipeline input from Get-MsecDefenderAlert by property name.

    .PARAMETER Status
        new, inProgress or resolved.

    .PARAMETER Classification
        unknown, falsePositive, truePositive or informationalExpectedActivity.

    .PARAMETER Determination
        The analyst's call on what it actually was. See the note above on the value names.

    .PARAMETER AssignedTo
        User principal name to assign the alert to.

    .EXAMPLE
        Connect-Msec -KeyVaultName kv-msec
        Connect-MsecAdmin

        Get-MsecDefenderAlert -Days 90 -Severity informational -Status new |
            Set-MsecDefenderAlert -Status resolved -Determination notMalicious -WhatIf

        Shows exactly which alerts would change, and nothing else. Drop -WhatIf once the list
        is the list you meant.

    .EXAMPLE
        Set-MsecDefenderAlert -Id $alertId -Status inProgress -AssignedTo me@contoso.com

        One alert, taken for investigation.

    .OUTPUTS
        One PSCustomObject per alert: Id, Title, Severity, ServiceSource, the status before, the
        state read back afterwards, and Changed.

    .NOTES
        Needs Connect-MsecAdmin with SecurityAlert.ReadWrite.All. One identity throughout: every
        call this command makes goes through that delegated session.

        Resolving an alert is a state change in Defender, not a local edit. -WhatIf lists the
        ids that would be touched.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
        [string[]] $Id,

        [ValidateSet('new', 'inProgress', 'resolved')]
        [string] $Status,

        [ValidateSet('unknown', 'falsePositive', 'truePositive', 'informationalExpectedActivity')]
        [string] $Classification,

        [ValidateSet('unknown', 'apt', 'malware', 'securityPersonnel', 'securityTesting',
                     'unwantedSoftware', 'other', 'multiStagedAttack', 'compromisedAccount',
                     'phishing', 'maliciousUserActivity', 'notMalicious',
                     'notEnoughDataToValidate', 'confirmedActivity', 'lineOfBusinessApplication')]
        [string] $Determination,

        [string] $AssignedTo
    )

    begin {
        Assert-MsecAdminSession -Scope 'SecurityAlert.ReadWrite.All'

        $body = [ordered]@{}
        if ($PSBoundParameters.ContainsKey('Status'))         { $body['status']         = $Status }
        if ($PSBoundParameters.ContainsKey('Classification')) { $body['classification'] = $Classification }
        if ($PSBoundParameters.ContainsKey('Determination'))  { $body['determination']  = $Determination }
        if ($PSBoundParameters.ContainsKey('AssignedTo'))     { $body['assignedTo']     = $AssignedTo }

        if (-not $body.Count) {
            throw ('Nothing to change. Pass at least one of -Status, -Classification, -Determination ' +
                   'or -AssignedTo.')
        }

        # Collected rather than written as they arrive, so duplicates are written once.
        $pending = [System.Collections.Generic.List[string]]::new()
    }

    process {
        foreach ($value in $Id) {
            $key = ([string] $value).Trim()
            if ($key) { $pending.Add($key) }
        }
    }

    end {
        $ids = @($pending | Sort-Object -Unique)
        if (-not $ids.Count) { return }

        $change = ($body.Keys | ForEach-Object { "$_=$($body[$_])" }) -join ', '

        foreach ($alertId in $ids) {
            if (-not $PSCmdlet.ShouldProcess($alertId, "Set $change")) { continue }

            $before = $null
            try { $before = Invoke-MsecAdminGraphRequest -Path "/v1.0/security/alerts_v2/$alertId" }
            catch {
                Write-Warning "Could not read alert $alertId before writing, so there is nothing to compare against: $_"
            }

            $graphWritten = $false
            try {
                $null = Invoke-MsecAdminGraphRequest -Path "/v1.0/security/alerts_v2/$alertId" -Method PATCH -Body $body
                $graphWritten = $true
            }
            catch {
                Write-Warning "Failed to update alert $alertId - $_"
            }

            # Re-read, allowing for eventual consistency - see Get-MsecAdminWriteResult.
            $after    = $null
            $mismatch = @()
            if ($graphWritten) {
                $verify = Get-MsecAdminWriteResult -Path "/v1.0/security/alerts_v2/$alertId" -Expected $body
                $after  = $verify.Object

                if (-not $after) {
                    Write-Warning "Alert $alertId was updated but could not be re-read, so the row below is unverified: $($verify.Error)"
                }
                elseif ($verify.Mismatch.Count) {
                    $mismatch = @($verify.Mismatch)
                    Write-Warning ("Alert $alertId still does not show: $($mismatch -join ', ') after $($verify.Attempts) " +
                                   'reads. The columns below are what Defender returned, not what was requested.')
                }
            }

            [PSCustomObject]@{
                PSTypeName          = 'MsecDefenderAlertChange'
                Id                  = $alertId
                Title               = [string] $before.title
                Severity            = [string] $before.severity
                ServiceSource       = [string] $before.serviceSource
                StatusBefore        = [string] $before.status
                # $null rather than the requested value when the re-read failed: an unverified
                # write must never render as a confirmed one.
                StatusAfter         = $(if ($after) { [string] $after.status } else { $null })
                ClassificationAfter = $(if ($after) { [string] $after.classification } else { $null })
                DeterminationAfter  = $(if ($after) { [string] $after.determination } else { $null })
                AssignedToAfter     = $(if ($after) { [string] $after.assignedTo } else { $null })
                Changed             = $(if ($after) { -not $mismatch.Count } else { $null })
            }
        }
    }
}