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")
    $uri = "https://management.azure.com$($Context.ResourceId)" +
           "/providers/Microsoft.SecurityInsights/incidents?api-version=2021-10-01&`$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)

    $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)
            Owner                      = if ($props.owner) { $props.owner.userPrincipalName } else { $null }
            Raw                         = $incident
        }
    }

    @($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
        Checks if SentinelHealth table is available (health monitoring enabled), then queries for
        automation rule run events. Cross-references with known close-incident automation rules
        to return a set of incident numbers that were auto-closed.
    .OUTPUTS
        Hashtable of IncidentNumber (int) -> $true, or $null if SentinelHealth is unavailable.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][PSCustomObject]$Context,
        [int]$DaysBack = 90,
        [string[]]$CloseRuleNames = @()
    )

    $headers = @{
        Authorization  = "Bearer $($Context.LaToken)"
        'Content-Type' = 'application/json'
    }
    $baseUri = "https://api.loganalytics.io/v1/workspaces/$($Context.WorkspaceId)/query"

    # Check if SentinelHealth table exists
    $checkQuery = 'SentinelHealth | take 1'
    $checkBody = @{ query = $checkQuery } | ConvertTo-Json -Compress
    try {
        $checkResponse = Invoke-AzRestWithRetry -Uri $baseUri -Method Post -Headers $headers -Body $checkBody
        if (-not $checkResponse.tables -or $checkResponse.tables[0].rows.Count -eq 0) {
            Write-Verbose 'SentinelHealth table exists but has no data.'
        }
    }
    catch {
        Write-Verbose "SentinelHealth table not available: $_"
        return $null
    }

    # 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 {
        Write-Warning "Failed to query SentinelHealth for auto-close data: $_"
        return $null
    }

    $rows = $response.tables[0].rows
    Write-Verbose "SentinelHealth returned $($rows.Count) automation rule run event(s)."

    if ($rows.Count -eq 0) {
        return @{}
    }

    # If we have close rule names, filter to only those rules; otherwise return all
    $autoClosedSet = @{}
    foreach ($row in $rows) {
        $incidentNum = [int]$row[0]
        $ruleName    = "$($row[1])"

        if ($CloseRuleNames.Count -eq 0 -or $ruleName -in $CloseRuleNames) {
            $autoClosedSet[$incidentNum] = $true
        }
    }

    Write-Verbose "Identified $($autoClosedSet.Count) auto-closed incident(s) from SentinelHealth."
    $autoClosedSet
}