public/Set-MsecSentinelIncident.ps1
|
function Set-MsecSentinelIncident { <# .SYNOPSIS Change a Microsoft Sentinel incident - status, classification, owner or severity - including the ones Defender XDR cannot see. .DESCRIPTION The write half of Get-MsecSentinelIncident. Set-MsecDefenderIncident cannot touch an incident whose ProviderName is 'Azure Sentinel', because it never reaches the XDR incident API at all. THE API HAS NO PATCH, ONLY PUT, AND PUT REPLACES THE RESOURCE. Sending just the fields being changed silently discards everything else on the incident - owner, labels, description, the original title. So this GETs the incident first and puts the existing properties back alongside the change. Any hand-written script that skips the GET is quietly destroying data it never read. THE ETAG IS CONCURRENCY CONTROL, NOT DECORATION. It is sent as If-Match, so a write fails rather than overwriting a change somebody made between the read and the write - which, on a shared incident queue, is a thing that happens. A CLASSIFICATION NEEDS A REASON, EXCEPT WHEN IT MUST NOT HAVE ONE. BenignPositive, TruePositive and FalsePositive each require -ClassificationReason; Undetermined requires its absence. Checked here so the failure names the rule, rather than arriving as a bare 400 from ARM. CLASSIFICATION ONLY APPLIES TO A CLOSED INCIDENT. The API accepts classification fields only when status is Closed, so passing -Classification without -Status Closed is refused here rather than silently ignored. IT RUNS ON THE Az CONTEXT, NOT ON Connect-MsecAdmin - UNLIKE Set-MsecDefenderIncident, and that difference is forced rather than chosen. Defender XDR incidents are a Graph resource reached with delegated Graph scopes; Sentinel incidents are an ARM resource reached with Azure RBAC. An ARM endpoint will not accept a Graph token, so there is no version of this command that uses the admin session. Needs Microsoft Sentinel Responder on the workspace. The msec app holds Graph permissions and is deliberately not given this. ACCOUNTABILITY IS THE SAME; THE CEREMONY IS NOT. Both commands write as a named human - Sentinel records the change under your own UPN - so "writes belong to a person" still holds. What differs is that the Az context is already present and cached, where Connect-MsecAdmin wants a browser. That makes this runnable unattended, which is genuinely useful and is also the thing to be deliberate about: Azure RBAC tends to be STANDING, broader and quieter than a Graph admin consent, so the role on the workspace is worth reviewing as carefully as the consent would have been. -Confirm IS THE REMAINING CHECKPOINT. ConfirmImpact is High, so an interactive caller is asked before every write and an unattended one has to pass -Confirm:$false on purpose. That explicit opt-out is what replaces the sign-in prompt - do not scatter it through a script to silence the noise. .PARAMETER Number The incident number as shown in the portal. Resolved to the underlying GUID by reading the workspace first. .PARAMETER Name The incident's GUID, as returned by Get-MsecSentinelIncident. Skips the lookup. .PARAMETER WorkspaceName The workspace holding the incident. Required - an incident number is only unique within one workspace. .PARAMETER SubscriptionId Subscription to act in. Defaults to the active Az context. .PARAMETER ResourceGroupName Narrows the workspace search. .PARAMETER Status New, Active or Closed. .PARAMETER Classification Why the incident was closed. Requires -Status Closed. .PARAMETER ClassificationReason Required for BenignPositive, TruePositive and FalsePositive; must be omitted for Undetermined. .PARAMETER ClassificationComment Free text recorded with the closure. Worth writing - it is the only part of a closed incident that explains the decision to the next reader. .PARAMETER Owner UPN to assign. Pass an empty string to unassign. .PARAMETER Severity High, Medium, Low or Informational. .PARAMETER PassThru Emit the incident as it stands after the change. .EXAMPLE Get-MsecSentinelIncident -WorkspaceName contoso-sentinel | Where-Object { $_.Status -eq 'New' -and $_.ProviderName -eq 'Azure Sentinel' } | Set-MsecSentinelIncident -WorkspaceName contoso-sentinel -Status Closed ` -Classification BenignPositive -ClassificationReason SuspiciousButExpected ` -ClassificationComment 'Pre-XDR backlog, reviewed in bulk' -WhatIf Dry-run the backlog nobody could close from Defender. Drop -WhatIf to commit. .OUTPUTS One PSCustomObject per incident changed, with the before and after of each field touched. With -PassThru, the full incident instead. .NOTES Needs Connect-MsecAzure and Microsoft Sentinel Responder on the workspace. Runs as YOU, not as the msec app. #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'ByNumber')] [OutputType([PSCustomObject])] param( [Parameter(Mandatory, ParameterSetName = 'ByNumber', ValueFromPipelineByPropertyName)] [int] $Number, [Parameter(Mandatory, ParameterSetName = 'ByName', ValueFromPipelineByPropertyName)] [string] $Name, [Parameter(Mandatory, ValueFromPipelineByPropertyName)] [string] $WorkspaceName, [string] $SubscriptionId, [string] $ResourceGroupName, [ValidateSet('New', 'Active', 'Closed')] [string] $Status, [ValidateSet('BenignPositive', 'TruePositive', 'FalsePositive', 'Undetermined')] [string] $Classification, [ValidateSet('SuspiciousButExpected', 'SuspiciousActivity', 'IncorrectAlertLogic', 'InaccurateData')] [string] $ClassificationReason, [string] $ClassificationComment, [string] $Owner, [ValidateSet('High', 'Medium', 'Low', 'Informational')] [string] $Severity, [switch] $PassThru ) begin { if (-not ($PSBoundParameters.Keys | Where-Object { $_ -in 'Status', 'Classification', 'ClassificationReason', 'ClassificationComment', 'Owner', 'Severity' })) { throw 'Nothing to change. Pass at least one of -Status, -Classification, -ClassificationReason, -ClassificationComment, -Owner or -Severity.' } # See the help: the API's own rules, checked before the call so the error names them. if ($Classification -and $Classification -ne 'Undetermined' -and -not $ClassificationReason) { throw "-Classification $Classification requires -ClassificationReason. Only 'Undetermined' may be set without one." } if ($Classification -eq 'Undetermined' -and $ClassificationReason) { throw "-Classification Undetermined must NOT carry a -ClassificationReason. Omit it, or choose a different classification." } if ($Classification -and $Status -and $Status -ne 'Closed') { throw "-Classification applies only to a closed incident, but -Status $Status was passed. Use -Status Closed, or drop the classification." } if ($Classification -and -not $Status) { throw '-Classification applies only to a closed incident. Pass -Status Closed as well.' } $script:SentinelResolved = @{} } process { $key = "$SubscriptionId|$ResourceGroupName|$WorkspaceName" if (-not $script:SentinelResolved.ContainsKey($key)) { $r = Resolve-MsecSentinelWorkspace -SubscriptionId $SubscriptionId ` -ResourceGroupName $ResourceGroupName -WorkspaceName $WorkspaceName if (-not $r.Workspaces.Count) { throw "Workspace '$WorkspaceName' was not found." } if ($r.Workspaces.Count -gt 1) { throw ("'$WorkspaceName' matched $($r.Workspaces.Count) workspaces. Pass -ResourceGroupName to disambiguate - " + 'writing to the wrong one is not recoverable from here.') } $script:SentinelResolved[$key] = $r } $resolved = $script:SentinelResolved[$key] $headers = $resolved.Headers $workspace = $resolved.Workspaces[0] $incidentName = $Name if ($PSCmdlet.ParameterSetName -eq 'ByNumber') { $match = @(Get-MsecSentinelIncident -SubscriptionId $SubscriptionId ` -ResourceGroupName $ResourceGroupName -WorkspaceName $WorkspaceName | Where-Object { $_.Number -eq $Number }) if (-not $match.Count) { throw "No incident numbered $Number in workspace '$WorkspaceName'." } $incidentName = $match[0].Name } $url = "$($workspace.Base)/incidents/$incidentName" + '?api-version=2024-09-01' # READ BEFORE WRITE, ALWAYS. PUT replaces - see the help. try { $before = Invoke-MsecRetryableRequest -What 'Azure Resource Manager' -RequestArgs @{ Headers = $headers; Uri = $url; ErrorAction = 'Stop' } } catch { $detail = "$($_.Exception.Message)" if ($detail -match '403|Forbidden') { throw ("Forbidden reading incident $incidentName in '$($workspace.Name)'. Writing needs the " + "'Microsoft Sentinel Responder' role on the workspace - Azure RBAC, not Entra. Original error: $detail") } throw "Could not read incident $incidentName before writing: $detail" } $p = $before.properties $changes = [System.Collections.Generic.List[string]]::new() foreach ($field in @( @{ Name = 'Status'; Old = $p.status; New = $Status } @{ Name = 'Classification'; Old = $p.classification; New = $Classification } @{ Name = 'ClassificationReason'; Old = $p.classificationReason; New = $ClassificationReason } @{ Name = 'ClassificationComment'; Old = $p.classificationComment; New = $ClassificationComment } @{ Name = 'Severity'; Old = $p.severity; New = $Severity } )) { if ($PSBoundParameters.ContainsKey($field.Name) -and "$($field.Old)" -ne "$($field.New)") { $changes.Add("$($field.Name): '$($field.Old)' -> '$($field.New)'") } } if ($PSBoundParameters.ContainsKey('Owner') -and "$($p.owner.userPrincipalName)" -ne "$Owner") { $changes.Add("Owner: '$($p.owner.userPrincipalName)' -> '$Owner'") } if (-not $changes.Count) { Write-Verbose "Incident $($p.incidentNumber) already matches the requested values; nothing sent." return } # EVERY EXISTING PROPERTY CARRIED FORWARD, then the change applied on top. $body = @{ properties = @{ title = $p.title description = $p.description severity = $(if ($PSBoundParameters.ContainsKey('Severity')) { $Severity } else { $p.severity }) status = $(if ($PSBoundParameters.ContainsKey('Status')) { $Status } else { $p.status }) firstActivityTimeUtc = $p.firstActivityTimeUtc lastActivityTimeUtc = $p.lastActivityTimeUtc labels = @($p.labels) } } if ($PSBoundParameters.ContainsKey('Owner')) { $body.properties['owner'] = @{ userPrincipalName = $(if ($Owner) { $Owner } else { $null }) } } elseif ($p.owner) { $body.properties['owner'] = $p.owner } $finalStatus = $body.properties['status'] if ($finalStatus -eq 'Closed') { $body.properties['classification'] = $(if ($PSBoundParameters.ContainsKey('Classification')) { $Classification } else { $p.classification }) $body.properties['classificationComment'] = $(if ($PSBoundParameters.ContainsKey('ClassificationComment')) { $ClassificationComment } else { $p.classificationComment }) $reason = $(if ($PSBoundParameters.ContainsKey('ClassificationReason')) { $ClassificationReason } else { $p.classificationReason }) if ($reason -and $body.properties['classification'] -ne 'Undetermined') { $body.properties['classificationReason'] = $reason } } $target = "incident $($p.incidentNumber) ('$($p.title)') in $($workspace.Name)" if (-not $PSCmdlet.ShouldProcess($target, "Set $($changes -join '; ')")) { return } $writeHeaders = $headers.Clone() if ($before.etag) { $writeHeaders['If-Match'] = $before.etag } try { # RETRIED ON 429. ARM throttles per subscription, and a bulk close is one PUT per # incident - reported from a live run of 240, one write was throttled. Without the # retry that lands mid-run with some incidents written and some not. $after = Invoke-MsecRetryableRequest -What 'Azure Resource Manager' -RequestArgs @{ Headers = $writeHeaders; Method = 'Put'; ContentType = 'application/json' Uri = $url; Body = ($body | ConvertTo-Json -Depth 8); ErrorAction = 'Stop' } } catch { $detail = "$($_.Exception.Message)" if ($detail -match '412|PreconditionFailed') { throw ("Incident $($p.incidentNumber) changed between the read and the write, so nothing was " + 'written - the etag no longer matched. Re-read it and try again; somebody else is working on it.') } if ($detail -match '403|Forbidden') { throw ("Forbidden writing incident $($p.incidentNumber) in '$($workspace.Name)'. This needs the " + "'Microsoft Sentinel Responder' role on the workspace - Azure RBAC, not Entra, so the msec " + "app cannot help. Original error: $detail") } throw "Could not write incident $($p.incidentNumber): $detail" } if ($PassThru) { Get-MsecSentinelIncident -SubscriptionId $SubscriptionId -ResourceGroupName $ResourceGroupName ` -WorkspaceName $WorkspaceName | Where-Object { $_.Name -eq $incidentName } return } [PSCustomObject]@{ PSTypeName = 'MsecSentinelIncidentChange' Number = [int] $p.incidentNumber Title = [string] $p.title WorkspaceName = $workspace.Name Changed = @($changes) StatusBefore = [string] $p.status StatusAfter = [string] $after.properties.status ProviderName = [string] $p.providerName Name = $incidentName } } } |