modules/normalizers/Normalize-AzGovViz.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Normalizer for AzGovViz findings.
.DESCRIPTION
    Converts raw AzGovViz wrapper output to v3 FindingRow objects.
    Platform=Azure, EntityType=ManagementGroup or AzureResource depending on the finding.
#>

[CmdletBinding()]
param ()

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

function Get-PropertyValue {
    param ([object]$Obj, [string]$Name, [object]$Default = '')
    if ($null -eq $Obj) { return $Default }
    $p = $Obj.PSObject.Properties[$Name]
    if ($null -eq $p -or $null -eq $p.Value) { return $Default }
    return $p.Value
}

function Get-StringArrayValue {
    param ([object]$Obj, [string]$Name)
    $value = Get-PropertyValue -Obj $Obj -Name $Name -Default @()
    if ($null -eq $value) { return @() }
    if ($value -is [System.Array]) {
        return @($value | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
    }
    if ($value -is [string]) {
        if ([string]::IsNullOrWhiteSpace($value)) { return @() }
        return @($value -split '[,;|]' | ForEach-Object { $_.Trim() } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
    }
    return @([string]$value)
}

function Get-HashtableArrayValue {
    param ([object]$Obj, [string]$Name)
    $value = Get-PropertyValue -Obj $Obj -Name $Name -Default @()
    if ($null -eq $value) { return @() }
    $items = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($entry in @($value)) {
        if ($null -eq $entry) { continue }
        if ($entry -is [System.Collections.IDictionary]) {
            $map = @{}
            foreach ($key in $entry.Keys) {
                $map[[string]$key] = $entry[$key]
            }
            $items.Add($map) | Out-Null
            continue
        }

        if ($entry.PSObject) {
            $map = @{}
            foreach ($prop in @($entry.PSObject.Properties)) {
                $map[$prop.Name] = $prop.Value
            }
            if ($map.Count -gt 0) {
                $items.Add($map) | Out-Null
                continue
            }
        }

        $text = [string]$entry
        if (-not [string]::IsNullOrWhiteSpace($text)) {
            $items.Add(@{
                    language = 'text'
                    code     = $text.Trim()
                }) | Out-Null
        }
    }
    return @($items)
}

function Convert-ToRemediationSnippets {
    param ([string]$Remediation)

    if ([string]::IsNullOrWhiteSpace($Remediation)) { return @() }
    return @(
        @{
            language = 'text'
            code     = $Remediation.Trim()
        }
    )
}

function Get-AzGovVizPillar {
    param ([string]$Category, [string]$Title)

    $normalizedCategory = ($Category ?? '').Trim().ToLowerInvariant()
    $normalizedTitle = ($Title ?? '').Trim().ToLowerInvariant()
    if ($normalizedCategory -match '^(policy|identity)$') { return 'Security' }
    if ($normalizedCategory -match '^(cost|costoptimization|finops)$') { return 'Cost' }
    if ($normalizedTitle -match 'orphaned') { return 'Cost' }
    return 'Operational Excellence'
}

function Get-AzGovVizImpact {
    param (
        [string]$Severity,
        [string]$Category
    )

    switch -Regex (($Severity ?? '').Trim().ToLowerInvariant()) {
        'critical|high' { return 'High' }
        'medium' { return 'Medium' }
        'low|info' { return 'Low' }
    }

    switch -Regex (($Category ?? '').Trim().ToLowerInvariant()) {
        '^(policy|identity)$' { return 'High' }
        '^(cost|costoptimization|finops)$' { return 'Medium' }
        default { return 'Medium' }
    }
}

function Get-AzGovVizEffort {
    param ([string]$Category)

    switch -Regex (($Category ?? '').Trim().ToLowerInvariant()) {
        '^identity$' { return 'High' }
        '^(policy|operations)$' { return 'Medium' }
        default { return 'Low' }
    }
}

function Resolve-AzGovVizEntity {
    param ([psobject]$Finding)

    $rawId = Get-PropertyValue $Finding 'ResourceId' ''
    $scope = Get-PropertyValue $Finding 'Scope' ''
    $category = Get-PropertyValue $Finding 'Category' 'Governance'
    $principalId = Get-PropertyValue $Finding 'PrincipalId' ''
    $principalType = Get-PropertyValue $Finding 'PrincipalType' ''
    $managementGroupResourceId = Get-PropertyValue $Finding 'ManagementGroupResourceId' ''
    $managementGroupId = Get-PropertyValue $Finding 'ManagementGroupId' ''
    $tenantId = Get-PropertyValue $Finding 'TenantId' ''
    $subId = ''
    $rg = ''
    $canonicalId = ''
    $entityType = 'ManagementGroup'
    $platformOverride = $null

    if ($category -eq 'Identity' -and $principalId) {
        $principalTypeValue = $principalType.ToLowerInvariant()
        $prefixedId = if ($principalId -match '^(objectId|appId):') { $principalId } else { "objectId:$principalId" }
        if ($principalTypeValue -match 'user') {
            $entityType = 'User'
            $canonicalId = (ConvertTo-CanonicalEntityId -RawId $prefixedId -EntityType 'User').CanonicalId
        } else {
            $entityType = 'ServicePrincipal'
            $canonicalId = (ConvertTo-CanonicalEntityId -RawId $prefixedId -EntityType 'ServicePrincipal').CanonicalId
        }
        $platformOverride = 'Azure'
    }

    if ($rawId -match '/subscriptions/([^/]+)') { $subId = $Matches[1] }
    if ($scope -match '/subscriptions/([^/]+)') { $subId = $Matches[1] }
    if ($rawId -match '/resourceGroups/([^/]+)') { $rg = $Matches[1] }

    if (-not $canonicalId) {
        $candidate = @($rawId, $scope, $managementGroupResourceId) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1
        if ($candidate -and $candidate -match '^/providers/microsoft\.management/managementgroups/') {
            $entityType = 'ManagementGroup'
            $canonicalId = $candidate.ToLowerInvariant()
        } elseif ($candidate -and $candidate -match '^/subscriptions/[^/]+$') {
            $entityType = 'Subscription'
            if ($candidate -match '/subscriptions/([^/]+)') {
                $canonicalId = (ConvertTo-CanonicalEntityId -RawId $Matches[1] -EntityType 'Subscription').CanonicalId
            }
        } elseif ($candidate -and $candidate -match '^/subscriptions/') {
            $entityType = 'AzureResource'
            try {
                $canonicalId = (ConvertTo-CanonicalEntityId -RawId $candidate -EntityType 'AzureResource').CanonicalId
            } catch {
                $canonicalId = $candidate.ToLowerInvariant()
            }
        } elseif ($tenantId) {
            $entityType = 'Tenant'
            $canonicalId = (ConvertTo-CanonicalEntityId -RawId $tenantId -EntityType 'Tenant').CanonicalId
        } elseif ($managementGroupId) {
            $entityType = 'ManagementGroup'
            $canonicalId = "/providers/microsoft.management/managementgroups/$($managementGroupId.ToLowerInvariant())"
        } else {
            $entityType = 'ManagementGroup'
            $cat  = Get-PropertyValue $Finding 'Category' 'unknown'
            $ttl  = Get-PropertyValue $Finding 'Title' (Get-PropertyValue $Finding 'Description' 'unknown')
            $stableKey = "$cat/$ttl".ToLowerInvariant() -replace '[^a-z0-9/]', '-'
            $canonicalId = "azgovviz/$stableKey"
        }
    }

    return [pscustomobject]@{
        EntityType             = $entityType
        CanonicalId            = $canonicalId
        SubscriptionId         = $subId
        ResourceGroup          = $rg
        PlatformOverride       = $platformOverride
        ManagementGroupId      = $managementGroupId
        ManagementGroupResId   = $managementGroupResourceId
        TenantId               = $tenantId
    }
}

function Get-AzGovVizEntityRefs {
    param (
        [psobject]$Finding,
        [psobject]$EntityResolution
    )

    $refs = [System.Collections.Generic.List[string]]::new()
    $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $entityType = $EntityResolution.EntityType
    $entityId = $EntityResolution.CanonicalId

    function Add-Ref {
        param ([string]$RefId, [string]$RefType)
        if ([string]::IsNullOrWhiteSpace($RefId)) { return }
        try {
            $canonical = (ConvertTo-CanonicalEntityId -RawId $RefId -EntityType $RefType).CanonicalId
            if ($canonical -and $canonical -ne $entityId -and $seen.Add($canonical)) {
                $refs.Add($canonical)
            }
        } catch {
            return
        }
    }

    $subId = $EntityResolution.SubscriptionId
    if ($subId -and $entityType -ne 'Subscription') {
        Add-Ref -RefId $subId -RefType 'Subscription'
    }

    $mgRef = if ($EntityResolution.ManagementGroupResId) {
        $EntityResolution.ManagementGroupResId
    } elseif ($EntityResolution.ManagementGroupId) {
        "/providers/Microsoft.Management/managementGroups/$($EntityResolution.ManagementGroupId)"
    } else {
        ''
    }
    if ($mgRef -and $entityType -ne 'ManagementGroup') {
        Add-Ref -RefId $mgRef -RefType 'ManagementGroup'
    }

    $mgPath = Get-StringArrayValue -Obj $Finding -Name 'ManagementGroupPath'
    foreach ($mg in $mgPath) {
        $mgRefId = if ($mg -match '^/providers/microsoft\.management/managementgroups/') { $mg } else { "/providers/Microsoft.Management/managementGroups/$mg" }
        if ($entityType -ne 'ManagementGroup') {
            Add-Ref -RefId $mgRefId -RefType 'ManagementGroup'
        }
    }

    $parentMgId = Get-PropertyValue -Obj $Finding -Name 'ParentManagementGroupId' -Default ''
    if ($parentMgId) {
        Add-Ref -RefId "/providers/Microsoft.Management/managementGroups/$parentMgId" -RefType 'ManagementGroup'
    }

    if ($EntityResolution.TenantId -and $entityType -ne 'Tenant') {
        Add-Ref -RefId $EntityResolution.TenantId -RefType 'Tenant'
    }

    return @($refs)
}

function Convert-AzGovVizScopeToCanonicalId {
    param([string] $RawId)
    if ([string]::IsNullOrWhiteSpace($RawId)) { return '' }
    $trimmed = $RawId.Trim()
    try {
        if ($trimmed -match '^/providers/microsoft\.management/managementgroups/') {
            return (ConvertTo-CanonicalEntityId -RawId $trimmed -EntityType 'ManagementGroup').CanonicalId
        }
        if ($trimmed -match '^/subscriptions/[^/]+$') {
            if ($trimmed -match '/subscriptions/([^/]+)') {
                return (ConvertTo-CanonicalEntityId -RawId $Matches[1] -EntityType 'Subscription').CanonicalId
            }
        }
        if ($trimmed -match '^/subscriptions/[^/]+/resourcegroups/[^/]+$') {
            return $trimmed.ToLowerInvariant()
        }
        if ($trimmed -match '^/subscriptions/') {
            return (ConvertTo-CanonicalEntityId -RawId $trimmed -EntityType 'AzureResource').CanonicalId
        }
    } catch {
        return $trimmed.ToLowerInvariant()
    }
    return $trimmed.ToLowerInvariant()
}

function Normalize-AzGovViz {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [PSCustomObject] $ToolResult,
        [System.Collections.Generic.List[psobject]] $EdgeCollector
    )

    if ($ToolResult.Status -ne 'Success' -or -not $ToolResult.Findings) {
        return @()
    }

    $runId = [guid]::NewGuid().ToString()
    $normalized = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($finding in $ToolResult.Findings) {
        $rawId = Get-PropertyValue $finding 'ResourceId' ''
        $category = Get-PropertyValue $finding 'Category' 'Governance'
        $entityResolution = Resolve-AzGovVizEntity -Finding $finding
        $title = Get-PropertyValue $finding 'Title' (Get-PropertyValue $finding 'Description' 'Unknown')

        $rawSev = Get-PropertyValue $finding 'Severity' 'Info'
        $severity = switch -Regex ($rawSev.ToString().ToLowerInvariant()) {
            'critical'         { 'Critical' }
            'high'             { 'High' }
            'medium|moderate'  { 'Medium' }
            'low'              { 'Low' }
            'info'             { 'Info' }
            default            { 'Medium' }
        }

        # Determine compliance
        $compliantProp = $finding.PSObject.Properties['Compliant']
        $compliant = if ($null -eq $compliantProp) { $true } else { $compliantProp.Value -ne $false }

        $detail = Get-PropertyValue $finding 'Detail' ''
        $remediation = Get-PropertyValue $finding 'Remediation' ''
        $learnMore = Get-PropertyValue $finding 'LearnMoreUrl' (Get-PropertyValue $finding 'LearnMoreLink' '')
        $pillar = Get-PropertyValue $finding 'Pillar' ''
        if (-not $pillar) {
            $pillar = Get-AzGovVizPillar -Category $category -Title $title
        }
        $frameworks = Get-PropertyValue $finding 'Frameworks' @()
        $baselineTags = Get-StringArrayValue -Obj $finding -Name 'BaselineTags'
        $evidenceUris = Get-StringArrayValue -Obj $finding -Name 'EvidenceUris'
        $impact = [string](Get-PropertyValue $finding 'Impact' '')
        if (-not $impact) {
            $impact = Get-AzGovVizImpact -Severity $severity -Category $category
        }
        $effort = [string](Get-PropertyValue $finding 'Effort' '')
        if (-not $effort) {
            $effort = Get-AzGovVizEffort -Category $category
        }
        $remediationSnippets = @(Get-HashtableArrayValue -Obj $finding -Name 'RemediationSnippets')
        if (@($remediationSnippets).Count -eq 0) {
            $remediationSnippets = @(Convert-ToRemediationSnippets -Remediation $remediation)
        }
        $scoreDelta = $null
        $rawScoreDelta = Get-PropertyValue $finding 'ScoreDelta' $null
        if ($null -ne $rawScoreDelta -and -not [string]::IsNullOrWhiteSpace([string]$rawScoreDelta)) {
            $parsedScore = 0.0
            if ([double]::TryParse(
                    [string]$rawScoreDelta,
                    [System.Globalization.NumberStyles]::Any,
                    [System.Globalization.CultureInfo]::InvariantCulture,
                    [ref]$parsedScore
                )) {
                $scoreDelta = $parsedScore
            }
        }
        $mitreTactics = Get-StringArrayValue -Obj $finding -Name 'MitreTactics'
        if (@($mitreTactics).Count -eq 0) {
            $mitreTactics = Get-StringArrayValue -Obj $finding -Name 'Tactics'
        }
        $mitreTechniques = Get-StringArrayValue -Obj $finding -Name 'MitreTechniques'
        if (@($mitreTechniques).Count -eq 0) {
            $mitreTechniques = Get-StringArrayValue -Obj $finding -Name 'Techniques'
        }
        $entityRefs = Get-AzGovVizEntityRefs -Finding $finding -EntityResolution $entityResolution
        $toolVersion = Get-PropertyValue $finding 'ToolVersion' ''
        $deepLinkUrl = Get-PropertyValue $finding 'DeepLinkUrl' ''
        $mgPath = Get-StringArrayValue -Obj $finding -Name 'ManagementGroupPath'

        $newFindingParams = @{
            Id              = ([guid]::NewGuid().ToString())
            Source          = 'azgovviz'
            EntityId        = $entityResolution.CanonicalId
            EntityType      = $entityResolution.EntityType
            Title           = $title
            Compliant       = [bool]$compliant
            ProvenanceRunId = $runId
            Category        = $category
            Severity        = $severity
            Detail          = $detail
            Remediation     = $remediation
            LearnMoreUrl    = ($learnMore ?? '')
            ResourceId      = ($rawId ?? '')
            SubscriptionId  = $entityResolution.SubscriptionId
            ResourceGroup   = $entityResolution.ResourceGroup
            ManagementGroupPath = $mgPath
             Frameworks      = $frameworks
             Pillar          = $pillar
             Impact          = $impact
             Effort          = $effort
             DeepLinkUrl     = $deepLinkUrl
             RemediationSnippets = $remediationSnippets
             EvidenceUris    = $evidenceUris
             BaselineTags    = $baselineTags
             ScoreDelta      = $scoreDelta
             MitreTactics    = $mitreTactics
             MitreTechniques = $mitreTechniques
             EntityRefs      = $entityRefs
             ToolVersion     = $toolVersion
        }
        if ($entityResolution.PlatformOverride) {
            $newFindingParams.Platform = $entityResolution.PlatformOverride
        }

        $row = New-FindingRow @newFindingParams
        # Skip null rows (validation failed)
        if ($null -ne $row) {
            $normalized.Add($row)
        }

        if ($null -ne $EdgeCollector) {
            $assignmentId = [string](Get-PropertyValue -Obj $finding -Name 'PolicyAssignmentId' -Default (Get-PropertyValue -Obj $finding -Name 'AssignmentId' -Default ''))
            $definitionId = [string](Get-PropertyValue -Obj $finding -Name 'PolicyDefinitionId' -Default (Get-PropertyValue -Obj $finding -Name 'DefinitionId' -Default ''))
            $exemptionId = [string](Get-PropertyValue -Obj $finding -Name 'PolicyExemptionId' -Default (Get-PropertyValue -Obj $finding -Name 'ExemptionId' -Default ''))
            $scopeId = [string](Get-PropertyValue -Obj $finding -Name 'Scope' -Default (Get-PropertyValue -Obj $finding -Name 'ScopeId' -Default ''))
            if ([string]::IsNullOrWhiteSpace($scopeId)) { $scopeId = [string](Get-PropertyValue -Obj $finding -Name 'ResourceId' -Default '') }
            $canonicalScopeId = Convert-AzGovVizScopeToCanonicalId -RawId $scopeId
            if ([string]::IsNullOrWhiteSpace($canonicalScopeId)) { $canonicalScopeId = [string]$entityResolution.CanonicalId }

            if ($assignmentId -and $canonicalScopeId) {
                $edge = New-Edge -Source $assignmentId.ToLowerInvariant() -Target $canonicalScopeId -Relation 'PolicyAssignedTo' -Platform 'Azure' -DiscoveredBy 'azgovviz'
                if ($edge) { $EdgeCollector.Add($edge) | Out-Null }
            }

            if ($assignmentId -and $definitionId) {
                $edge = New-Edge -Source $assignmentId.ToLowerInvariant() -Target $definitionId.ToLowerInvariant() -Relation 'PolicyEnforces' -Platform 'Azure' -DiscoveredBy 'azgovviz'
                if ($edge) { $EdgeCollector.Add($edge) | Out-Null }
            }

            if ($canonicalScopeId -and $assignmentId) {
                $edge = New-Edge -Source $canonicalScopeId -Target $assignmentId.ToLowerInvariant() -Relation 'ExemptedFrom' -Platform 'Azure' -DiscoveredBy 'azgovviz'
                if ($edge) { $EdgeCollector.Add($edge) | Out-Null }
            }

            $parentScopeRaw = [string](Get-PropertyValue -Obj $finding -Name 'ParentScopeId' -Default (Get-PropertyValue -Obj $finding -Name 'ParentManagementGroupResourceId' -Default ''))
            if ([string]::IsNullOrWhiteSpace($parentScopeRaw)) {
                $parentMg = [string](Get-PropertyValue -Obj $finding -Name 'ParentManagementGroupId' -Default '')
                if ($parentMg) {
                    $parentScopeRaw = "/providers/Microsoft.Management/managementGroups/$parentMg"
                }
            }
            $parentScopeId = Convert-AzGovVizScopeToCanonicalId -RawId $parentScopeRaw
            if ($canonicalScopeId -and $parentScopeId -and $canonicalScopeId -ne $parentScopeId) {
                $edge = New-Edge -Source $canonicalScopeId -Target $parentScopeId -Relation 'InheritsFrom' -Platform 'Azure' -DiscoveredBy 'azgovviz'
                if ($edge) { $EdgeCollector.Add($edge) | Out-Null }
            }
        }
    }

    return @($normalized)
}