Private/Get-Incidents.ps1
|
function Get-Incidents { <# .SYNOPSIS Fetches Sentinel incidents and normalizes fields used by Detection Analyzer. .OUTPUTS Array of PSCustomObject incidents. #> [CmdletBinding()] param( [Parameter(Mandatory)][PSCustomObject]$Context, [ValidateRange(1, 365)][int]$DaysBack = 90 ) $headers = @{ Authorization = "Bearer $($Context.ArmToken)" } $since = (Get-Date).ToUniversalTime().AddDays(-$DaysBack).ToString('o') $escapedSince = [System.Uri]::EscapeDataString("properties/createdTimeUtc ge $since") # $top max is 1000 per the Incidents List API $uri = "$(Get-LogHorizonEndpoint -Name Arm -Context $Context)$($Context.ResourceId)" + "/providers/Microsoft.SecurityInsights/incidents?api-version=2025-09-01&`$top=1000&`$filter=$escapedSince" $allIncidents = [System.Collections.Generic.List[object]]::new() $maxPages = 1000 $pageCount = 0 do { $pageCount++ $response = Invoke-AzRestWithRetry -Uri $uri -Headers $headers foreach ($incident in $response.value) { $allIncidents.Add($incident) } $uri = $response.nextLink if ($pageCount -ge $maxPages) { Write-Warning 'Pagination limit reached fetching incidents. Stopping to avoid infinite loop.' break } } while ($uri) Write-Verbose "Fetched $($allIncidents.Count) incident(s) across $pageCount page(s)." $normalized = foreach ($incident in $allIncidents) { $props = $incident.properties $created = ConvertTo-UtcDateOrNull -Value $props.createdTimeUtc $closed = ConvertTo-UtcDateOrNull -Value $props.closedTimeUtc $modified = ConvertTo-UtcDateOrNull -Value $props.lastModifiedTimeUtc [PSCustomObject]@{ IncidentId = $incident.name IncidentNumber = [int]$props.incidentNumber Title = $props.title Status = $props.status Severity = $props.severity Classification = $props.classification ClassificationReason = $props.classificationReason CreatedTimeUtc = $created ClosedTimeUtc = $closed LastModifiedTimeUtc = $modified RelatedAnalyticRuleIds = @(Get-NormalizedArray -Value $props.relatedAnalyticRuleIds) RelatedAnalyticRuleNames = @(Get-NormalizedArray -Value $props.relatedAnalyticRuleNames) Etag = $incident.etag } } @($normalized) } function ConvertTo-UtcDateOrNull { [CmdletBinding()] param([object]$Value) if ($null -eq $Value -or [string]::IsNullOrWhiteSpace("$Value")) { return $null } try { return ([datetime]$Value).ToUniversalTime() } catch { return $null } } function Get-NormalizedArray { [CmdletBinding()] param([object]$Value) if ($null -eq $Value) { return @() } if ($Value -is [System.Array]) { return @($Value | ForEach-Object { "$_" } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) } if ($Value -is [string]) { if ([string]::IsNullOrWhiteSpace($Value)) { return @() } return @($Value) } return @("$Value") } function Get-AutoCloseFromHealth { <# .SYNOPSIS Queries SentinelHealth for automation rule run events to determine auto-closed incidents. .DESCRIPTION Queries SentinelHealth for automation rule run events and returns the set of incident numbers touched by the supplied close-incident automation rules. Without CloseRuleNames there is nothing to attribute, so an empty set is returned rather than treating every automation run (tagging, assignment) as an auto-close. Returns $null when the SentinelHealth table is not available. .OUTPUTS Hashtable of IncidentNumber (int) -> $true, or $null if SentinelHealth is unavailable. #> [CmdletBinding()] param( [Parameter(Mandatory)][PSCustomObject]$Context, [int]$DaysBack = 90, [string[]]$CloseRuleNames = @() ) $CloseRuleNames = @($CloseRuleNames | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($CloseRuleNames.Count -eq 0) { Write-Verbose 'No enabled close-incident automation rules supplied; skipping SentinelHealth auto-close attribution.' return @{} } $headers = @{ Authorization = "Bearer $($Context.LaToken)" 'Content-Type' = 'application/json' } $baseUri = "$(Get-LogHorizonEndpoint -Name LogAnalytics -Context $Context)/workspaces/$($Context.WorkspaceId)/query" # Query automation rule run events $query = @" SentinelHealth | where TimeGenerated > ago(${DaysBack}d) | where OperationName == "Automation rule run" | where Status in ("Success", "Partial success") | extend props = parse_json(ExtendedProperties) | extend IncidentNumber = toint(props.IncidentNumber) | extend RuleName = SentinelResourceName | where isnotempty(IncidentNumber) | project IncidentNumber, RuleName | distinct IncidentNumber, RuleName "@ $body = @{ query = $query } | ConvertTo-Json -Compress try { $response = Invoke-AzRestWithRetry -Uri $baseUri -Method Post -Headers $headers -Body $body } catch { if (Test-KqlTableMissingError -ErrorRecord $_ -TableName 'SentinelHealth') { Write-Verbose 'SentinelHealth table not available (health monitoring not enabled).' } else { Write-Warning "Failed to query SentinelHealth for auto-close data: $($_.Exception.Message)" } return $null } $rows = @($response.tables[0].rows) Write-Verbose "SentinelHealth returned $($rows.Count) automation rule run event(s)." $autoClosedSet = @{} foreach ($row in $rows) { $incidentNum = [int]$row[0] $ruleName = "$($row[1])" if ($ruleName -in $CloseRuleNames) { $autoClosedSet[$incidentNum] = $true } } Write-Verbose "Identified $($autoClosedSet.Count) auto-closed incident(s) from SentinelHealth." $autoClosedSet } function Test-KqlTableMissingError { <# .SYNOPSIS True when a Log Analytics query error indicates the table does not exist in the workspace (semantic error, "Failed to resolve table"). #> [CmdletBinding()] param( [Parameter(Mandatory)][System.Management.Automation.ErrorRecord]$ErrorRecord, [string]$TableName ) $text = "$($ErrorRecord.Exception.Message) $($ErrorRecord.ErrorDetails.Message)" if ($text -match '(?i)SemanticError|Failed to resolve (table|scalar expression)|could not be resolved') { if ([string]::IsNullOrWhiteSpace($TableName)) { return $true } return ($text -match [regex]::Escape($TableName)) } $false } |