modules/normalizers/Normalize-SentinelIncidents.ps1
|
#Requires -Version 7.4 <# .SYNOPSIS Normalizer for Microsoft Sentinel incidents wrapper output. .DESCRIPTION Converts v1 sentinel-incidents wrapper output to v2 FindingRows. - Each active incident maps to EntityType=AzureResource (workspace ARM resource), keyed to the Log Analytics workspace hosting Sentinel. - Severity is mapped from Sentinel's native values (High/Medium/Low/Informational). - All incidents are Compliant=false (active, unresolved incidents). - Extra fields (IncidentNumber, IncidentStatus, AlertCount, Classification, IncidentUrl, ProviderName) are attached via Add-Member. #> [CmdletBinding()] param () . "$PSScriptRoot\..\shared\Schema.ps1" . "$PSScriptRoot\..\shared\Canonicalize.ps1" function Normalize-SentinelIncidents { [CmdletBinding()] param ( [Parameter(Mandatory)] [PSCustomObject] $ToolResult ) if ($ToolResult.Status -ne 'Success' -or -not $ToolResult.Findings) { return @() } $runId = [guid]::NewGuid().ToString() $normalized = [System.Collections.Generic.List[PSCustomObject]]::new() function ConvertTo-StringArray { param ([object] $Value) if ($null -eq $Value) { return @() } $items = if ($Value -is [string]) { $trimmed = $Value.Trim() if ([string]::IsNullOrWhiteSpace($trimmed)) { @() } elseif ($trimmed.StartsWith('[') -or $trimmed.StartsWith('{')) { try { @($trimmed | ConvertFrom-Json -Depth 30) } catch { @($trimmed) } } else { @($trimmed) } } elseif ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { @($Value) } else { @($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 } $text = $text.Trim() if ($seen.Add($text)) { $result.Add($text) } } return $result.ToArray() } function ConvertTo-Frameworks { param ( [object] $FrameworksRaw, [string[]] $MitreTechniques ) $frameworks = [System.Collections.Generic.List[hashtable]]::new() foreach ($raw in @($FrameworksRaw)) { if ($null -eq $raw) { continue } if ($raw -is [System.Collections.IDictionary]) { $frameworks.Add(@{ Name = [string]$raw['Name'] Controls = @($raw['Controls']) ControlId = [string]($raw['ControlId'] ?? $raw['controlId']) kind = [string]($raw['kind'] ?? $raw['Name']) }) | Out-Null continue } if ($raw.PSObject) { $frameworks.Add(@{ Name = [string]$raw.Name Controls = @($raw.Controls) ControlId = [string]($raw.ControlId ?? $raw.controlId) kind = [string]($raw.kind ?? $raw.Name) }) | Out-Null } } if ($frameworks.Count -eq 0) { foreach ($techniqueId in @($MitreTechniques)) { if ([string]::IsNullOrWhiteSpace([string]$techniqueId)) { continue } $frameworks.Add(@{ Name = 'MITRE ATT&CK' Controls = @($techniqueId) ControlId = [string]$techniqueId kind = 'MITRE ATT&CK' }) | Out-Null } } return $frameworks.ToArray() } foreach ($f in $ToolResult.Findings) { $rawId = if ($f.PSObject.Properties['ResourceId'] -and $f.ResourceId) { [string]$f.ResourceId } else { '' } if (-not $rawId) { continue } $subId = '' $rg = '' if ($rawId -match '/subscriptions/([^/]+)') { $subId = $Matches[1] } if ($rawId -match '/resourceGroups/([^/]+)') { $rg = $Matches[1] } # Sentinel incidents are workspace-scoped; entity is the workspace ARM resource $entityType = 'AzureResource' try { $canonicalId = (ConvertTo-CanonicalEntityId -RawId $rawId -EntityType 'AzureResource').CanonicalId } catch { $canonicalId = $rawId.ToLowerInvariant() } # Map Sentinel severity to schema casing (Critical/High/Medium/Low/Info) $sevRaw = if ($f.PSObject.Properties['Severity'] -and $f.Severity) { [string]$f.Severity } else { 'Medium' } $sev = switch -Regex ($sevRaw) { '^(?i)critical$' { 'Critical' } '^(?i)high$' { 'High' } '^(?i)medium$' { 'Medium' } '^(?i)low$' { 'Low' } '^(?i)info.*' { 'Info' } default { 'Medium' } } $compliant = $false if ($f.PSObject.Properties['Compliant']) { $compliant = [bool]$f.Compliant } $findingId = if ($f.PSObject.Properties['Id'] -and $f.Id) { [string]$f.Id } else { [guid]::NewGuid().ToString() } $remediation = if ($f.PSObject.Properties['Remediation']) { [string]$f.Remediation } else { '' } $mitreTactics = ConvertTo-StringArray -Value $(if ($f.PSObject.Properties['MitreTactics']) { $f.MitreTactics } else { @() }) $mitreTechniques = ConvertTo-StringArray -Value $(if ($f.PSObject.Properties['MitreTechniques']) { $f.MitreTechniques } else { @() }) $entityRefs = ConvertTo-StringArray -Value $(if ($f.PSObject.Properties['EntityRefs']) { $f.EntityRefs } else { @() }) $evidenceUris = ConvertTo-StringArray -Value $(if ($f.PSObject.Properties['EvidenceUris']) { $f.EvidenceUris } else { @() }) $frameworks = ConvertTo-Frameworks -FrameworksRaw $(if ($f.PSObject.Properties['Frameworks']) { $f.Frameworks } else { @() }) -MitreTechniques $mitreTechniques $deepLink = if ($f.PSObject.Properties['DeepLinkUrl'] -and $f.DeepLinkUrl) { [string]$f.DeepLinkUrl } elseif ($f.PSObject.Properties['IncidentUrl'] -and $f.IncidentUrl) { [string]$f.IncidentUrl } else { '' } $toolVersion = if ($f.PSObject.Properties['ToolVersion'] -and $f.ToolVersion) { [string]$f.ToolVersion } else { '2022-10-01' } $pillar = if ($f.PSObject.Properties['Pillar'] -and $f.Pillar) { [string]$f.Pillar } else { 'Security' } $row = New-FindingRow -Id $findingId ` -Source 'sentinel-incidents' -EntityId $canonicalId -EntityType $entityType ` -Title ([string]$f.Title) -Compliant $compliant -ProvenanceRunId $runId ` -Platform 'Azure' -Category 'ThreatDetection' -Severity $sev ` -Detail ([string]$f.Detail) ` -Remediation $remediation ` -LearnMoreUrl ([string]$f.LearnMoreUrl) -ResourceId $rawId ` -SubscriptionId $subId -ResourceGroup $rg ` -Pillar $pillar ` -Frameworks $frameworks ` -DeepLinkUrl $deepLink ` -EvidenceUris $evidenceUris ` -MitreTactics $mitreTactics ` -MitreTechniques $mitreTechniques ` -EntityRefs $entityRefs ` -ToolVersion $toolVersion if ($null -ne $row) { $normalized.Add($row) } } return @($normalized) } |