public/Set-MsecDefenderIncident.ps1

function Set-MsecDefenderIncident {
    <#
    .SYNOPSIS
        Resolve, classify or comment on Defender XDR incidents. Runs as YOU, through
        Connect-MsecAdmin.

    .DESCRIPTION
        THE RESOLUTION COMMENT LIVES HERE, NOT ON THE ALERT. Graph has no writable comment on
        an alert: `comments` is read-only on alerts_v2 in both v1.0 and beta, there is no
        comments navigation property and no action to add one, and neither Update alert doc
        lists it as updatable. Incidents have -ResolvingComment, described by Microsoft as
        "user input that explains the resolution of the incident and the classification
        choice" - which is the note people actually want when they close something. Alerts roll
        up into incidents, so commenting on the incident is both the supported path and the one
        an analyst reads first.

        Same guards as Set-MsecDefenderAlert. It requires the Connect-MsecAdmin session and
        refuses the app one; it collects piped ids before the first write so duplicates are written
        once; and it re-reads each incident afterwards and reports what came back,
        not what was asked for - polling briefly, because XDR settles asynchronously and a single
        immediate read reports changes as lost that in fact land a moment later.

        -CustomTags REPLACES the tag array, it does not append - that is how Graph treats the
        collection. The existing tags are shown as CustomTagsBefore so a replacement is at
        least visible; read them first if you meant to add one.

        -Status takes the values that can actually be SET. 'redirected' is excluded on purpose:
        it is what Defender assigns when it merges an incident into another, not a state you
        move an incident to. Note also that 'inProgress' and 'awaitingAction' are real - they
        are in Graph's $metadata even though the Update incident doc lists only active,
        resolved and redirected.

        DETERMINATION VALUES: 'notMalicious' and 'notEnoughDataToValidate', from $metadata.
        Microsoft's own Update alert page still lists the retired 'clean' and 'insufficientData'
        for the very same shared enum - the Update incident page and $metadata agree with each
        other and with this command, so do not "fix" these from that stale page.

    .PARAMETER Id
        Incident ids. Takes pipeline input from Get-MsecDefenderIncident by property name.

    .PARAMETER Status
        active, inProgress, awaitingAction or resolved. See the note above on 'redirected'.

    .PARAMETER Classification
        unknown, falsePositive, truePositive or informationalExpectedActivity.

    .PARAMETER Determination
        The analyst's call on what it actually was.

    .PARAMETER ResolvingComment
        Free text explaining the resolution and the classification choice.

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

    .PARAMETER Severity
        Re-grade the incident: informational, low, medium or high.

    .PARAMETER CustomTags
        REPLACES the incident's custom tags. Not an append.


    .EXAMPLE
        Connect-Msec -KeyVaultName kv-msec
        Connect-MsecAdmin -Scope SecurityIncident.ReadWrite.All

        Get-MsecDefenderIncident -Days 90 -Status active |
            Where-Object Severity -eq 'informational' |
            Set-MsecDefenderIncident -Status resolved -Classification informationalExpectedActivity `
                -Determination notMalicious -ResolvingComment 'Expected scanner activity - see CHG0042' -WhatIf

    .EXAMPLE
        Set-MsecDefenderIncident -Id 4711 -Status resolved -Classification falsePositive `
            -Determination notMalicious -ResolvingComment 'Pen test, authorised, ticket SEC-88'

    .OUTPUTS
        One PSCustomObject per incident: Id, DisplayName, Severity, the status before, the
        state read back afterwards, and Changed.

    .NOTES
        Needs Connect-MsecAdmin with SecurityIncident.ReadWrite.All.

        displayName, summary and description are updatable through Graph but are deliberately
        not exposed here - they are the incident's narrative, not a triage decision, and
        rewriting them from a pipeline is a good way to lose Defender's own text.
    #>

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

        [ValidateSet('active', 'inProgress', 'awaitingAction', '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] $ResolvingComment,

        [string] $AssignedTo,

        [ValidateSet('informational', 'low', 'medium', 'high')]
        [string] $Severity,

        [string[]] $CustomTags
    )

    begin {
        Assert-MsecAdminSession -Scope 'SecurityIncident.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('ResolvingComment')) { $body['resolvingComment'] = $ResolvingComment }
        if ($PSBoundParameters.ContainsKey('AssignedTo'))       { $body['assignedTo']       = $AssignedTo }
        if ($PSBoundParameters.ContainsKey('Severity'))         { $body['severity']         = $Severity }
        if ($PSBoundParameters.ContainsKey('CustomTags'))       { $body['customTags']       = @($CustomTags) }

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

        # 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 {
            if ($_ -eq 'customTags') { "customTags=[$(@($CustomTags) -join '; ')]" } else { "$_=$($body[$_])" }
        }) -join ', '

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

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

            if ($PSBoundParameters.ContainsKey('CustomTags') -and $before -and @($before.customTags).Count) {
                Write-Warning ("Incident $incidentId already has tags [$(@($before.customTags) -join '; ')] and " +
                               '-CustomTags replaces rather than appends - those are about to be dropped.')
            }

            try {
                $null = Invoke-MsecAdminGraphRequest -Path "/v1.0/security/incidents/$incidentId" -Method PATCH -Body $body
            }
            catch {
                Write-Warning "Failed to update incident $incidentId - $_"
                continue
            }

            # The PATCH response echoes the request; this asks the service what the incident IS.
            # Polled rather than read once: XDR settles asynchronously and a single immediate
            # read reports changes as lost that land a moment later.
            $verify   = Get-MsecAdminWriteResult -Path "/v1.0/security/incidents/$incidentId" -Expected $body
            $after    = $verify.Object
            $mismatch = @()

            if (-not $after) {
                Write-Warning "Incident $incidentId 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 ("Incident $incidentId still does not show: $($mismatch -join ', ') after " +
                               "$($verify.Attempts) reads. The columns below are what Defender returned, " +
                               'not what was requested.')
            }

            [PSCustomObject]@{
                PSTypeName            = 'MsecDefenderIncidentChange'
                Id                    = $incidentId
                DisplayName           = [string] $before.displayName
                Severity              = [string] $before.severity
                StatusBefore          = [string] $before.status
                CustomTagsBefore      = @($before.customTags)
                # $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 })
                ResolvingCommentAfter = $(if ($after) { [string] $after.resolvingComment } else { $null })
                AssignedToAfter       = $(if ($after) { [string] $after.assignedTo } else { $null })
                CustomTagsAfter       = $(if ($after) { @($after.customTags) } else { $null })
                Changed               = $(if ($after) { -not $mismatch.Count } else { $null })
            }
        }
    }
}