modules/Invoke-SentinelIncidents.ps1
|
#requires -Version 7.0 <# .SYNOPSIS Wrapper for Microsoft Sentinel -- active incidents and alerts from a Log Analytics workspace. .DESCRIPTION Queries the Log Analytics workspace API with KQL against the SecurityIncident table. Emits a v1 tool-result shape that the Normalize-SentinelIncidents normalizer converts into v2 FindingRows. Incidents land on the AzureResource entity (workspace ARM resource). Each incident carries severity, status, classification, and linked alert count so the EntityStore can fold Sentinel findings next to existing Defender for Cloud recommendations. Uses Invoke-WithRetry for transient 429/503/timeout handling. Gracefully skips when the workspace is unreachable or the SecurityIncident table does not exist. .PARAMETER WorkspaceResourceId Full ARM resource ID of the Log Analytics workspace linked to Sentinel. Example: /subscriptions/<guid>/resourceGroups/<rg>/providers/Microsoft.OperationalInsights/workspaces/<name> .PARAMETER LookbackDays Number of days to look back for incidents. Default 30. .PARAMETER OutputPath Optional directory for raw API JSON (for audit). #> [CmdletBinding()] param ( [Parameter(Mandatory)] [string] $WorkspaceResourceId, [ValidateRange(1, 365)] [int] $LookbackDays = 30, [string] $OutputPath ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $retryPath = Join-Path $PSScriptRoot 'shared' 'Retry.ps1' if (Test-Path $retryPath) { . $retryPath } if (-not (Get-Command Invoke-WithRetry -ErrorAction SilentlyContinue)) { function Invoke-WithRetry { param([scriptblock]$ScriptBlock, [int]$MaxAttempts = 3) & $ScriptBlock } } $sanitizePath = Join-Path $PSScriptRoot 'shared' 'Sanitize.ps1' if (Test-Path $sanitizePath) { . $sanitizePath } if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) { function Remove-Credentials { param([string]$Text) return $Text } } $errorsPath = Join-Path $PSScriptRoot 'shared' 'Errors.ps1' if (Test-Path $errorsPath) { . $errorsPath } $envelopePath = Join-Path $PSScriptRoot 'shared' 'New-WrapperEnvelope.ps1' if (Test-Path $envelopePath) { . $envelopePath } if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) { function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } } } if (-not (Get-Command New-FindingError -ErrorAction SilentlyContinue)) { function New-FindingError { param([string]$Source,[string]$Category,[string]$Reason,[string]$Remediation,[string]$Details) return [pscustomobject]@{ Source=$Source; Category=$Category; Reason=$Reason; Remediation=$Remediation; Details=$Details } } } if (-not (Get-Command Format-FindingErrorMessage -ErrorAction SilentlyContinue)) { function Format-FindingErrorMessage { param([Parameter(Mandatory)]$FindingError) $line = "[{0}] {1}: {2}" -f $FindingError.Source, $FindingError.Category, $FindingError.Reason if ($FindingError.Remediation) { $line += " Action: $($FindingError.Remediation)" } return $line } } # Extract subscription ID from the workspace ARM ID $subId = '' if ($WorkspaceResourceId -match '/subscriptions/([^/]+)') { $subId = $Matches[1] } $result = [ordered]@{ SchemaVersion = '1.0' Source = 'sentinel-incidents' Status = 'Success' Message = '' Findings = @() Errors = @() Subscription = $subId Timestamp = (Get-Date).ToUniversalTime().ToString('o') } if (-not (Get-Module -ListAvailable -Name Az.Accounts)) { $result.Status = 'Skipped' $result.Message = 'Az.Accounts module not installed. Run: Install-Module Az.Accounts -Scope CurrentUser' return [pscustomobject]$result } Import-Module Az.Accounts -ErrorAction SilentlyContinue try { $ctx = Get-AzContext -ErrorAction Stop if (-not $ctx) { Write-Error 'No Az context' -ErrorAction Stop } } catch { $result.Status = 'Skipped' $result.Message = 'Not signed in. Run Connect-AzAccount first.' return [pscustomobject]$result } # Validate workspace ARM ID format if ($WorkspaceResourceId -notmatch '^/subscriptions/[^/]+/resourceGroups/[^/]+/providers/Microsoft\.OperationalInsights/workspaces/[^/]+$') { $result.Status = 'Failed' $result.Message = 'Invalid WorkspaceResourceId format. Expected: /subscriptions/{guid}/resourceGroups/{rg}/providers/Microsoft.OperationalInsights/workspaces/{name}' return [pscustomobject]$result } $findings = [System.Collections.Generic.List[object]]::new() $workspaceApiVersion = '2022-10-01' function ConvertTo-ObjectArray { param ([object] $Value) if ($null -eq $Value) { return @() } if ($Value -is [string]) { $trimmed = $Value.Trim() if ([string]::IsNullOrWhiteSpace($trimmed)) { return @() } if ($trimmed.StartsWith('[') -or $trimmed.StartsWith('{')) { try { $parsed = $trimmed | ConvertFrom-Json -Depth 30 return @($parsed) } catch { return @($trimmed) } } return @($trimmed) } if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { return @($Value) } return @($Value) } function ConvertTo-StringArray { param ([object] $Value) $items = ConvertTo-ObjectArray -Value $Value $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $result = [System.Collections.Generic.List[string]]::new() foreach ($item in $items) { if ($null -eq $item) { continue } $text = [string]$item if ([string]::IsNullOrWhiteSpace($text)) { continue } $trimmed = $text.Trim() if ($seen.Add($trimmed)) { $result.Add($trimmed) } } return $result.ToArray() } function Get-ObjectPropertyValueSafe { param ( [object] $Object, [string[]] $PropertyNames ) foreach ($name in @($PropertyNames)) { if ([string]::IsNullOrWhiteSpace($name)) { continue } if ($Object -is [System.Collections.IDictionary] -and $Object.Contains($name)) { $value = $Object[$name] if (-not [string]::IsNullOrWhiteSpace([string]$value)) { return [string]$value } } if ($Object.PSObject -and $Object.PSObject.Properties[$name]) { $value = $Object.$name if (-not [string]::IsNullOrWhiteSpace([string]$value)) { return [string]$value } } } return '' } function Get-SentinelEntityRefs { param ([object] $Value) $items = ConvertTo-ObjectArray -Value $Value $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $refs = [System.Collections.Generic.List[string]]::new() foreach ($item in $items) { if ($null -eq $item) { continue } if ($item -is [string]) { $raw = $item.Trim().ToLowerInvariant() if ($raw -match '^(account|host|ip|filehash):' -and $seen.Add($raw)) { $refs.Add($raw) } continue } $entityType = (Get-ObjectPropertyValueSafe -Object $item -PropertyNames @('Type', 'EntityType', 'type')).ToLowerInvariant() if (-not $entityType) { continue } $ref = '' switch -Regex ($entityType) { '^account$' { $accountId = Get-ObjectPropertyValueSafe -Object $item -PropertyNames @('AadUserId', 'AccountAadUserId', 'UserPrincipalName', 'UPN', 'Name', 'Sid', 'ObjectGuid') if ($accountId) { $ref = "account:$($accountId.ToLowerInvariant())" } } '^host$' { $hostName = Get-ObjectPropertyValueSafe -Object $item -PropertyNames @('HostName', 'DnsDomain', 'MachineName', 'NtHostName', 'Name') if ($hostName) { $ref = "host:$($hostName.ToLowerInvariant())" } } '^ip$' { $ip = Get-ObjectPropertyValueSafe -Object $item -PropertyNames @('Address', 'IpAddress', 'AddressV4', 'AddressV6', 'Name') if ($ip) { $ref = "ip:$($ip.ToLowerInvariant())" } } '^filehash$' { $hash = Get-ObjectPropertyValueSafe -Object $item -PropertyNames @('Value', 'HashValue', 'Sha256', 'Sha1', 'Md5', 'FileHash') if ($hash) { $ref = "filehash:$($hash.ToLowerInvariant())" } } } if ($ref -and $seen.Add($ref)) { $refs.Add($ref) } } return $refs.ToArray() } function Get-EvidenceUris { param ( [object] $Comments, [object] $RelatedEntities, [string] $IncidentDeepLink ) $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $uris = [System.Collections.Generic.List[string]]::new() foreach ($raw in @($Comments, $RelatedEntities)) { foreach ($item in (ConvertTo-ObjectArray -Value $raw)) { if ($null -eq $item) { continue } if ($item -is [string]) { $candidate = $item.Trim() if ($candidate -match '^https://') { if ($seen.Add($candidate)) { $uris.Add($candidate) } } continue } $uri = Get-ObjectPropertyValueSafe -Object $item -PropertyNames @('Url', 'Uri', 'Link') if ($uri -and $uri -match '^https://') { if ($seen.Add($uri)) { $uris.Add($uri) } } } } if ($IncidentDeepLink) { foreach ($suffix in @('comments', 'entities')) { $uri = "$($IncidentDeepLink.TrimEnd('/'))/$suffix" if ($seen.Add($uri)) { $uris.Add($uri) } } } return $uris.ToArray() } function Get-ColumnValue { param ( [object[]] $Row, [hashtable] $ColumnIndex, [string] $Name ) if (-not $ColumnIndex.ContainsKey($Name)) { return $null } $idx = [int]$ColumnIndex[$Name] if ($idx -lt 0 -or $idx -ge $Row.Count) { return $null } return $Row[$idx] } # --- 1. Query SecurityIncident table --- # SecurityIncident is append-only: every update writes a new row. # Dedup to the latest row per IncidentNumber, then filter to active. $queryUri = "https://management.azure.com${WorkspaceResourceId}/api/query?api-version=$workspaceApiVersion" $incidentKql = @" SecurityIncident | where TimeGenerated > ago(${LookbackDays}d) | summarize arg_max(TimeGenerated, *) by IncidentNumber | where Status in ('New', 'Active') | extend AlertCount = array_length(AlertIds) | extend IncidentAdditionalData = todynamic(column_ifexists('AdditionalData', dynamic({}))) | extend Tactics = todynamic(IncidentAdditionalData.Tactics) | extend Techniques = todynamic(IncidentAdditionalData.Techniques) | extend Comments = todynamic(column_ifexists('Comments', dynamic([]))) | extend RelatedEntities = todynamic(column_ifexists('RelatedEntities', dynamic([]))) | project IncidentNumber, Title, Severity, Status, Classification, Owner = tostring(Owner.assignedTo), IncidentUrl, ProviderName, CreatedTime, LastModifiedTime, Description, AlertCount, Tactics, Techniques, Comments, RelatedEntities | order by case(Severity, "High", 1, "Medium", 2, "Low", 3, "Informational", 4, 5), CreatedTime desc "@ try { $body = @{ query = $incidentKql } | ConvertTo-Json -Depth 5 $incResp = Invoke-WithRetry -MaxAttempts 3 -ScriptBlock { Invoke-AzRestMethod -Method POST -Uri $queryUri -Payload $body -ErrorAction Stop } if (-not $incResp -or $incResp.StatusCode -ge 400) { $statusCode = if ($incResp) { $incResp.StatusCode } else { 'null' } $content = if ($incResp) { $incResp.Content } else { 'No response' } # HTTP 404: workspace or resource not found $isTableNotFound = ($incResp -and $incResp.StatusCode -eq 404) # HTTP 400 with semantic error indicating table does not exist # Log Analytics returns SemanticError with "could not be resolved" for missing tables if (-not $isTableNotFound -and $incResp -and $incResp.StatusCode -eq 400) { $isTableNotFound = ($content -match 'SemanticError' -and $content -match 'could not be resolved') -or ($content -match "'SecurityIncident'" -and $content -match 'not found') } if ($isTableNotFound) { $result.Status = 'Skipped' $result.Message = "SecurityIncident table not available (HTTP $statusCode). Sentinel may not be enabled on this workspace." return [pscustomobject]$result } throw (Format-FindingErrorMessage (New-FindingError ` -Source 'wrapper:sentinel-incidents' ` -Category 'TransientFailure' ` -Reason "Log Analytics query API returned status ${statusCode}." ` -Remediation 'Verify Log Analytics Reader role on the workspace and retry.' ` -Details (Remove-Credentials -Text ([string]$content)))) } $queryResult = $incResp.Content | ConvertFrom-Json -Depth 20 # Parse tabular response (columns + rows) $tables = $queryResult.tables if (-not $tables -or $tables.Count -eq 0) { $result.Message = 'No active Sentinel incidents found.' $result.Findings = @() return [pscustomobject]$result } $columns = $tables[0].columns $rows = $tables[0].rows if (-not $rows -or $rows.Count -eq 0) { $result.Message = 'No active Sentinel incidents in the lookback window.' $result.Findings = @() return [pscustomobject]$result } # Build column-name index $colIdx = @{} for ($i = 0; $i -lt $columns.Count; $i++) { $colIdx[$columns[$i].name] = $i } foreach ($row in $rows) { $incNumber = [string](Get-ColumnValue -Row $row -ColumnIndex $colIdx -Name 'IncidentNumber') $title = [string](Get-ColumnValue -Row $row -ColumnIndex $colIdx -Name 'Title') $severity = [string](Get-ColumnValue -Row $row -ColumnIndex $colIdx -Name 'Severity') $status = [string](Get-ColumnValue -Row $row -ColumnIndex $colIdx -Name 'Status') $classification = [string](Get-ColumnValue -Row $row -ColumnIndex $colIdx -Name 'Classification') $owner = [string](Get-ColumnValue -Row $row -ColumnIndex $colIdx -Name 'Owner') $incUrl = [string](Get-ColumnValue -Row $row -ColumnIndex $colIdx -Name 'IncidentUrl') $provider = [string](Get-ColumnValue -Row $row -ColumnIndex $colIdx -Name 'ProviderName') $createdTime = [string](Get-ColumnValue -Row $row -ColumnIndex $colIdx -Name 'CreatedTime') $modifiedTime = [string](Get-ColumnValue -Row $row -ColumnIndex $colIdx -Name 'LastModifiedTime') $description = [string](Get-ColumnValue -Row $row -ColumnIndex $colIdx -Name 'Description') $alertCountRaw = Get-ColumnValue -Row $row -ColumnIndex $colIdx -Name 'AlertCount' $tacticsRaw = Get-ColumnValue -Row $row -ColumnIndex $colIdx -Name 'Tactics' $techniquesRaw = Get-ColumnValue -Row $row -ColumnIndex $colIdx -Name 'Techniques' $commentsRaw = Get-ColumnValue -Row $row -ColumnIndex $colIdx -Name 'Comments' $entitiesRaw = Get-ColumnValue -Row $row -ColumnIndex $colIdx -Name 'RelatedEntities' $alertCount = 0 if ($null -ne $alertCountRaw -and "$alertCountRaw" -ne '') { $alertCount = [int]$alertCountRaw } if (-not $severity) { $severity = 'Medium' } $mitreTactics = ConvertTo-StringArray -Value $tacticsRaw $mitreTechniques = ConvertTo-StringArray -Value $techniquesRaw $entityRefs = Get-SentinelEntityRefs -Value $entitiesRaw $frameworks = [System.Collections.Generic.List[hashtable]]::new() foreach ($techniqueId in $mitreTechniques) { $frameworks.Add(@{ Name = 'MITRE ATT&CK' Controls = @($techniqueId) ControlId = $techniqueId kind = 'MITRE ATT&CK' }) | Out-Null } $deepLink = $incUrl if (-not $deepLink -and $incNumber) { $escapedWorkspaceId = [System.Uri]::EscapeDataString($WorkspaceResourceId) $deepLink = "https://portal.azure.com/#view/Microsoft_Azure_Security_Insights/IncidentDetailsBlade/workspaceResourceId/$escapedWorkspaceId/incidentNumber/$incNumber" } $evidenceUris = Get-EvidenceUris -Comments $commentsRaw -RelatedEntities $entitiesRaw -IncidentDeepLink $deepLink $detail = if ($description) { $description } else { "Sentinel incident #$incNumber requires triage." } $findings.Add([pscustomobject]@{ Id = "sentinel/incident/$incNumber" Source = 'sentinel-incidents' Category = 'ThreatDetection' Severity = $severity Compliant = $false Title = $title Detail = $detail Remediation = "Investigate incident #$incNumber in the Sentinel portal and triage or resolve." ResourceId = $WorkspaceResourceId IncidentNumber = $incNumber IncidentStatus = $status Classification = $classification AlertCount = $alertCount IncidentUrl = $deepLink DeepLinkUrl = $deepLink ProviderName = $provider Owner = $owner Description = $description CreatedTime = $createdTime LastModifiedTime = $modifiedTime LearnMoreUrl = 'https://learn.microsoft.com/azure/sentinel/investigate-incidents' ToolVersion = $workspaceApiVersion Pillar = 'Security' MitreTactics = @($mitreTactics) MitreTechniques = @($mitreTechniques) Frameworks = @($frameworks) EntityRefs = @($entityRefs) EvidenceUris = @($evidenceUris) }) | Out-Null } } catch { $result.Status = 'Failed' $result.Message = "Sentinel incident query failed: $(Remove-Credentials -Text ([string]$_.Exception.Message))" return [pscustomobject]$result } $result.Findings = @($findings) $result.Message = "Found $($findings.Count) active Sentinel incident(s) in the last $LookbackDays day(s)." if ($OutputPath) { try { if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } $raw = Join-Path $OutputPath "sentinel-incidents-$(Get-Date -Format yyyyMMddHHmmss).json" Set-Content -Path $raw -Value (Remove-Credentials ($result | ConvertTo-Json -Depth 20)) -Encoding utf8 } catch { Write-Warning "Failed to write raw Sentinel JSON: $(Remove-Credentials -Text ([string]$_.Exception.Message))" } } return [pscustomobject]$result |