modules/normalizers/Normalize-SentinelCoverage.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Normalizer for Microsoft Sentinel coverage / posture wrapper output.
.DESCRIPTION
    Converts v1 sentinel-coverage wrapper output to v2 FindingRows.
    All findings target the workspace ARM resource (EntityType=AzureResource,
    Platform=Azure). Severity is mapped case-insensitively to the schema's
    five-level enum (Critical/High/Medium/Low/Info). Tool-specific metadata
    (RuleId, WatchlistAlias, ConnectorCount, HuntingQueryCount, etc.) is
    attached via Add-Member as out-of-schema extras.
#>

[CmdletBinding()]
param ()

. "$PSScriptRoot\..\shared\Schema.ps1"
. "$PSScriptRoot\..\shared\Canonicalize.ps1"

function Normalize-SentinelCoverage {
    [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()

    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] }

        try   { $canonicalId = (ConvertTo-CanonicalEntityId -RawId $rawId -EntityType 'AzureResource').CanonicalId }
        catch { $canonicalId = $rawId.ToLowerInvariant() }

        $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 { '' }
        $learnMore   = if ($f.PSObject.Properties['LearnMoreUrl']) { [string]$f.LearnMoreUrl } else { '' }
        $category    = if ($f.PSObject.Properties['Category'] -and $f.Category) { [string]$f.Category } else { 'ThreatDetection' }
        $ruleId      = if ($f.PSObject.Properties['RuleId'] -and $f.RuleId) { [string]$f.RuleId } else { '' }
        $pillar      = if ($f.PSObject.Properties['Pillar'] -and $f.Pillar) { [string]$f.Pillar } else { 'Security' }
        $toolVersion = if ($f.PSObject.Properties['ToolVersion'] -and $f.ToolVersion) { [string]$f.ToolVersion } else { '' }
        $deepLinkUrl = if ($f.PSObject.Properties['DeepLinkUrl'] -and $f.DeepLinkUrl) { [string]$f.DeepLinkUrl } else { '' }
        $frameworks  = if ($f.PSObject.Properties['Frameworks'] -and $f.Frameworks) { @($f.Frameworks) } else { @() }
        $mitreTactics = if ($f.PSObject.Properties['MitreTactics'] -and $f.MitreTactics) { @($f.MitreTactics | ForEach-Object { [string]$_ }) } else { @() }
        $mitreTechniques = if ($f.PSObject.Properties['MitreTechniques'] -and $f.MitreTechniques) { @($f.MitreTechniques | ForEach-Object { [string]$_ }) } else { @() }
        $entityRefs = if ($f.PSObject.Properties['EntityRefs'] -and $f.EntityRefs) {
            @($f.EntityRefs | ForEach-Object { [string]$_ })
        } else {
            @([string]$canonicalId)
        }

        $row = New-FindingRow -Id $findingId `
            -Source 'sentinel-coverage' -EntityId $canonicalId -EntityType 'AzureResource' `
            -Title ([string]$f.Title) -RuleId $ruleId -Compliant $compliant -ProvenanceRunId $runId `
            -Platform 'Azure' -Category $category -Severity $sev `
            -Detail ([string]$f.Detail) `
            -Remediation $remediation `
            -LearnMoreUrl $learnMore -ResourceId $rawId `
            -SubscriptionId $subId -ResourceGroup $rg `
            -Frameworks $frameworks `
            -Pillar $pillar `
            -DeepLinkUrl $deepLinkUrl `
            -MitreTactics $mitreTactics `
            -MitreTechniques $mitreTechniques `
            -EntityRefs $entityRefs `
            -ToolVersion $toolVersion

        if ($null -eq $row) { continue }

        # Attach Sentinel coverage extras (out-of-schema).
        foreach ($extra in 'RuleDisplayName', 'LastModifiedUtc', 'AgeDays',
                            'WatchlistAlias', 'WatchlistName', 'DefaultDuration', 'TtlDays', 'ItemCount',
                            'ConnectorCount', 'MinExpected',
                            'AnalyticRuleCount', 'HuntingQueryCount') {
            if ($f.PSObject.Properties[$extra] -and $null -ne $f.$extra) {
                $row | Add-Member -NotePropertyName $extra -NotePropertyValue $f.$extra -Force
            }
        }

        $normalized.Add($row)
    }

    return @($normalized)
}