modules/normalizers/Normalize-Azqr.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Normalizer for Azure Quick Review (azqr) findings.
.DESCRIPTION
    Converts raw azqr wrapper output to v3 FindingRow objects.
    Platform=Azure, EntityType=AzureResource.
#>

[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 Convert-ToStringArray {
    param ([object]$Value)
    if ($null -eq $Value) { return @() }
    $items = [System.Collections.Generic.List[string]]::new()
    if ($Value -is [string]) {
        if (-not [string]::IsNullOrWhiteSpace($Value)) { $items.Add($Value.Trim()) | Out-Null }
    } else {
        foreach ($item in @($Value)) {
            if ($null -eq $item) { continue }
            $text = [string]$item
            if (-not [string]::IsNullOrWhiteSpace($text)) { $items.Add($text.Trim()) | Out-Null }
        }
    }
    return @($items)
}

function Resolve-Pillar {
    param ([object]$Finding)

    $rawPillar = [string](Get-PropertyValue $Finding 'Pillar' '')
    if (-not [string]::IsNullOrWhiteSpace($rawPillar)) { return $rawPillar.Trim() }

    $category = [string](Get-PropertyValue $Finding 'Category' (Get-PropertyValue $Finding 'ServiceCategory' ''))
    switch -Regex ($category.Trim().ToLowerInvariant()) {
        'security|identity|networking|encryption' { return 'Security' }
        'reliability|highavailability|high availability|businesscontinuity' { return 'Reliability' }
        'cost|finops' { return 'CostOptimization' }
        'performance' { return 'PerformanceEfficiency' }
        'monitoring|monitoringandalerting|operational|operations|operationalexcellence' { return 'OperationalExcellence' }
        default { return '' }
    }
}

function Resolve-Frameworks {
    param (
        [object]$Finding,
        [string]$Pillar
    )

    $frameworks = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($fw in @(Get-PropertyValue $Finding 'Frameworks' @())) {
        if ($fw -is [System.Collections.IDictionary]) {
            $kind = [string]($fw['kind'] ?? $fw['Kind'] ?? '')
            $controlId = [string]($fw['controlId'] ?? $fw['ControlId'] ?? '')
            if (-not [string]::IsNullOrWhiteSpace($kind) -and -not [string]::IsNullOrWhiteSpace($controlId)) {
                $frameworks.Add(@{ kind = $kind.Trim(); controlId = $controlId.Trim() }) | Out-Null
                continue
            }

            $name = [string]($fw['Name'] ?? $fw['name'] ?? '')
            foreach ($control in @(Convert-ToStringArray ($fw['Controls'] ?? $fw['controls'] ?? @()))) {
                if (-not [string]::IsNullOrWhiteSpace($name)) {
                    $frameworks.Add(@{ kind = $name.Trim(); controlId = $control }) | Out-Null
                }
            }
        } elseif ($fw.PSObject -and $fw.PSObject.Properties['kind']) {
            $kind = [string]$fw.kind
            $controlId = [string](Get-PropertyValue $fw 'controlId' (Get-PropertyValue $fw 'ControlId' ''))
            if (-not [string]::IsNullOrWhiteSpace($kind) -and -not [string]::IsNullOrWhiteSpace($controlId)) {
                $frameworks.Add(@{ kind = $kind.Trim(); controlId = $controlId.Trim() }) | Out-Null
            }
        } elseif ($fw) {
            $text = [string]$fw
            if (-not [string]::IsNullOrWhiteSpace($text)) {
                $frameworks.Add(@{ kind = 'WAF'; controlId = $text.Trim() }) | Out-Null
            }
        }
    }

    if (-not [string]::IsNullOrWhiteSpace($Pillar)) {
        $frameworks.Add(@{ kind = 'WAF'; controlId = $Pillar }) | Out-Null
    }

    return @($frameworks)
}

function Convert-ToHashtableArray {
    param ([object]$Value)
    $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
        }

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

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

function Normalize-Azqr {
    [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 ($finding in $ToolResult.Findings) {
        $rawId = Get-PropertyValue $finding 'ResourceId' (Get-PropertyValue $finding 'Id' '')
        $subId = ''
        $rg = ''
        $canonicalId = ''
        $findingId = Get-PropertyValue $finding 'Id' ([guid]::NewGuid().ToString())

        if ($rawId -and $rawId -match '^/subscriptions/') {
            try {
                $canonicalMeta = ConvertTo-CanonicalEntityId -RawId $rawId -EntityType 'AzureResource'
                $canonicalId = $canonicalMeta.CanonicalId
            } catch {
                $canonicalId = $rawId.ToLowerInvariant()
            }
            if ($rawId -match '(?i)/subscriptions/([^/]+)') { $subId = $Matches[1].ToLowerInvariant() }
            if ($rawId -match '(?i)/resourcegroups/([^/]+)') { $rg = $Matches[1] }
        }

        # Synthesize entity ID when no ARM ID is available
        if (-not $canonicalId) {
            $fallbackSub = if ($subId -match '^[0-9a-fA-F-]{36}$') {
                $subId.ToLowerInvariant()
            } else {
                '00000000-0000-0000-0000-000000000000'
            }
            $fallbackArmId = "/subscriptions/$fallbackSub/providers/microsoft.resourcegraph/azqrfindings/$findingId"
            $canonicalMeta = ConvertTo-CanonicalEntityId -RawId $fallbackArmId -EntityType 'AzureResource'
            $canonicalId = $canonicalMeta.CanonicalId
        }

        $title = Get-PropertyValue $finding 'Recommendation' (Get-PropertyValue $finding 'Title' (Get-PropertyValue $finding 'Description' 'Unknown'))
        $category = Get-PropertyValue $finding 'Category' (Get-PropertyValue $finding 'ServiceCategory' 'General')

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

        # Determine compliance: azqr uses Result=OK or Compliant=$true
        $resultVal = Get-PropertyValue $finding 'Result' ''
        $compliantVal = Get-PropertyValue $finding 'Compliant' $null
        $compliant = ($resultVal -eq 'OK') -or ($compliantVal -eq $true)

        $detail = Get-PropertyValue $finding 'Notes' (Get-PropertyValue $finding 'Detail' (Get-PropertyValue $finding 'Description' ''))
        $remediation = Get-PropertyValue $finding 'Remediation' (Get-PropertyValue $finding 'Url' '')
        $learnMore = Get-PropertyValue $finding 'LearnMoreLink' (Get-PropertyValue $finding 'LearnMoreUrl' (Get-PropertyValue $finding 'Url' ''))
        $ruleId = [string](Get-PropertyValue $finding 'RecommendationId' (Get-PropertyValue $finding 'RuleId' ''))
        $pillar = Resolve-Pillar -Finding $finding
        $frameworks = Resolve-Frameworks -Finding $finding -Pillar $pillar
        $impact = [string](Get-PropertyValue $finding 'Impact' '')
        $effort = [string](Get-PropertyValue $finding 'Effort' '')
        $deepLinkUrl = [string](Get-PropertyValue $finding 'DeepLinkUrl' (Get-PropertyValue $finding 'PortalUrl' ''))
        $remediationSnippets = @(Convert-ToHashtableArray (Get-PropertyValue $finding 'RemediationSnippets' @()))
        $evidenceUris = @(Convert-ToStringArray (Get-PropertyValue $finding 'EvidenceUris' @()))
        $baselineTags = @(Convert-ToStringArray (Get-PropertyValue $finding 'BaselineTags' @()))
        $mitreTactics = @(Convert-ToStringArray (Get-PropertyValue $finding 'MitreTactics' (Get-PropertyValue $finding 'Tactics' @())))
        $mitreTechniques = @(Convert-ToStringArray (Get-PropertyValue $finding 'MitreTechniques' (Get-PropertyValue $finding 'Techniques' @())))
        $entityRefs = @(Convert-ToStringArray (Get-PropertyValue $finding 'EntityRefs' @()))
        $toolVersion = [string](Get-PropertyValue $finding 'ToolVersion' (Get-PropertyValue $ToolResult 'ToolVersion' ''))

        $row = New-FindingRow -Id ([guid]::NewGuid().ToString()) `
            -Source 'azqr' -EntityId $canonicalId -EntityType 'AzureResource' `
            -Title $title -Compliant ([bool]$compliant) -ProvenanceRunId $runId `
            -Platform 'Azure' -Category $category -Severity $severity `
            -Detail $detail -Remediation $remediation `
            -LearnMoreUrl $learnMore -ResourceId ($rawId ?? '') `
            -SubscriptionId $subId -ResourceGroup $rg `
            -RuleId $ruleId -Pillar $pillar -Impact $impact -Effort $effort `
            -DeepLinkUrl $deepLinkUrl -Frameworks $frameworks `
            -RemediationSnippets $remediationSnippets -EvidenceUris $evidenceUris `
            -BaselineTags $baselineTags -MitreTactics $mitreTactics `
            -MitreTechniques $mitreTechniques -EntityRefs $entityRefs `
            -ToolVersion $toolVersion
        # Skip null rows (validation failed)
        if ($null -ne $row) {
            $normalized.Add($row)
        }
    }

    return @($normalized)
}