public/Get-MsecSentinelIncident.ps1
|
function Get-MsecSentinelIncident { <# .SYNOPSIS One row per Microsoft Sentinel incident, read from the workspace itself rather than from Defender XDR - including the ones XDR has never seen. .DESCRIPTION PROVIDERNAME IS THE WHOLE REASON THIS EXISTS. A Sentinel workspace that was in use before it was connected to Defender XDR keeps those older incidents with ProviderName 'Azure Sentinel'. They are never synced to XDR, so Get-MsecDefenderIncident cannot see them at all - measured on one workspace, 628 of 3,721 incidents, and 53 of the 248 still open. AND THE SecurityIncident TABLE IS NOT A SUBSTITUTE. That table ages out with workspace retention - 31 days on the workspace measured - while the incidents themselves live in ARM far longer: the oldest OPEN incident found this way was created 21 months earlier. So an incident can be simultaneously open, invisible to XDR, and absent from KQL. Both of the obvious places to look return nothing, and nothing looks like 'clean'. IT PAGES, AND NOT PAGING IS A SILENT TRUNCATION. The API caps a page well below the total; the same workspace returned 3,721 incidents over four pages at the largest page size, and still had a nextLink after six pages at a smaller one. A single GET returns a confident, short, wrong answer. ENTITIES COST A POST PER INCIDENT, which is why -IncludeEntities is opt-in. The call is POST .../incidents/{name}/entities and it is a READ despite the verb - it changes nothing. On a few hundred incidents it roughly doubles the runtime. IT RUNS ON THE Az CONTEXT, AS YOU. Sentinel is an Azure resource, so this is Azure RBAC rather than Graph: the msec app holds Graph permissions and cannot read this. Needs Microsoft Sentinel Reader on the workspace. .PARAMETER SubscriptionId Subscription to read. Defaults to the active Az context. .PARAMETER ResourceGroupName Narrows the workspace search. .PARAMETER WorkspaceName A single workspace. Without it, every Sentinel-onboarded workspace in scope is read. .PARAMETER Status New, Active or Closed. Filtered server-side. Omit for every status. .PARAMETER Days Only incidents CREATED in the last N days. Filters on createdTimeUtc, not on last modified - 'what was opened recently' and 'what was touched recently' are different questions and this answers the first. .PARAMETER AnyAge Send no date filter at all. The default is also no filter; -AnyAge exists to say so explicitly in a script that might otherwise look like it forgot -Days. .PARAMETER IncludeEntities Fetch the entities for each incident - accounts, IPs, hosts, URLs, cloud applications. One extra request per incident; see the description. .EXAMPLE Get-MsecSentinelIncident -WorkspaceName contoso-sentinel -Status New | Group-Object ProviderName The split that matters: anything under 'Azure Sentinel' is invisible to Get-MsecDefenderIncident and will not be triaged by anyone working from XDR. .EXAMPLE Get-MsecSentinelIncident -WorkspaceName contoso-sentinel | Where-Object { $_.Status -ne 'Closed' -and $_.ProviderName -eq 'Azure Sentinel' } | Sort-Object CreatedUtc The backlog nobody can see from Defender, oldest first. .OUTPUTS One PSCustomObject per incident, PSTypeName 'MsecSentinelIncident'. Etag is carried so Set-MsecSentinelIncident can write without clobbering a concurrent change. Raw keeps the untouched ARM object. .NOTES Needs Connect-MsecAzure and Microsoft Sentinel Reader on the workspace. Runs as YOU, not as the msec app. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [string] $SubscriptionId, [string] $ResourceGroupName, [string] $WorkspaceName, [ValidateSet('New', 'Active', 'Closed')] [string] $Status, [ValidateRange(1, 3650)] [int] $Days, [switch] $AnyAge, [switch] $IncludeEntities ) if ($Days -and $AnyAge) { throw '-Days and -AnyAge contradict each other. -AnyAge means no date filter; omit one.' } $resolved = Resolve-MsecSentinelWorkspace -SubscriptionId $SubscriptionId ` -ResourceGroupName $ResourceGroupName -WorkspaceName $WorkspaceName if (-not $resolved.Workspaces.Count) { return } $headers = $resolved.Headers $filters = [System.Collections.Generic.List[string]]::new() if ($Status) { $filters.Add("properties/status eq '$Status'") } if ($Days) { $since = [datetime]::UtcNow.AddDays(-$Days).ToString('yyyy-MM-ddTHH:mm:ssZ') $filters.Add("properties/createdTimeUtc ge $since") } $unreadable = 0 foreach ($workspace in $resolved.Workspaces) { $query = "api-version=2024-09-01&`$top=1000" if ($filters.Count) { $query += '&$filter=' + [uri]::EscapeDataString(($filters -join ' and ')) } $uri = "$($workspace.Base)/incidents?$query" $raw = [System.Collections.Generic.List[object]]::new() try { # PAGED TO THE END. See the help - a single GET truncates silently. while ($uri) { $page = Invoke-RestMethod -Headers $headers -Uri $uri -ErrorAction Stop foreach ($i in @($page.value)) { $raw.Add($i) } $uri = $page.nextLink } } catch { $detail = "$($_.Exception.Message)" if ($detail -match '403|Forbidden') { throw ("Forbidden reading incidents in workspace '$($workspace.Name)'. This needs the " + "'Microsoft Sentinel Reader' role on the workspace, which is Azure RBAC and is NOT " + 'granted through Entra - the msec app cannot help here. Original error: ' + $detail) } # A workspace that is not onboarded to Sentinel 404s on this path. Counted and # warned about rather than thrown, so one ordinary log workspace does not stop a # subscription-wide read. if ($detail -match '404|NotFound') { Write-Verbose "Workspace '$($workspace.Name)' has no Sentinel incidents endpoint; not onboarded." continue } $unreadable++ Write-Warning "Could not read incidents in workspace '$($workspace.Name)': $detail" continue } foreach ($i in $raw) { $p = $i.properties $entities = $null if ($IncludeEntities) { try { # POST, and it is a read - the API expresses the expansion as a body. $e = Invoke-RestMethod -Headers $headers -Method Post -ContentType 'application/json' ` -Uri "$($workspace.Base)/incidents/$($i.name)/entities?api-version=2024-09-01" ` -Body '{}' -ErrorAction Stop $entities = @($e.entities | ForEach-Object { $ep = $_.properties [PSCustomObject]@{ Kind = [string] $_.kind Value = [string] $( if ($ep.accountName) { "$($ep.accountName)@$($ep.upnSuffix)" } elseif ($ep.address) { $ep.address } elseif ($ep.hostName) { $ep.hostName } elseif ($ep.url) { $ep.url } elseif ($ep.appName) { $ep.appName } else { $ep.friendlyName } ) } }) } catch { # $null, not @() - "could not read" must not look like "no entities". Write-Warning "Could not read entities for incident $($p.incidentNumber) in '$($workspace.Name)': $($_.Exception.Message)" } } [PSCustomObject]@{ PSTypeName = 'MsecSentinelIncident' Number = [int] $p.incidentNumber Title = [string] $p.title Severity = [string] $p.severity Status = [string] $p.status Classification = $(if ($p.classification) { [string] $p.classification } else { $null }) ClassificationReason = $(if ($p.classificationReason) { [string] $p.classificationReason } else { $null }) ClassificationComment = $(if ($p.classificationComment) { [string] $p.classificationComment } else { $null }) # THE FIELD THIS COMMAND EXISTS FOR - see the help. ProviderName = [string] $p.providerName Owner = $(if ($p.owner.userPrincipalName) { [string] $p.owner.userPrincipalName } else { $null }) CreatedUtc = $p.createdTimeUtc LastModifiedUtc = $p.lastModifiedTimeUtc AlertCount = [int] $p.additionalData.alertsCount AlertProductNames = @($p.additionalData.alertProductNames) Tactics = @($p.additionalData.tactics) RelatedAnalyticRuleIds = @($p.relatedAnalyticRuleIds) IncidentUrl = [string] $p.incidentUrl WorkspaceName = $workspace.Name Name = [string] $i.name Etag = [string] $i.etag Entities = $entities Raw = $i } } } if ($unreadable) { Write-Warning "$unreadable workspace(s) could not be read. The result is INCOMPLETE - this is not the same as those workspaces having no incidents." } } |