modules/normalizers/Normalize-AlzQueries.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Normalizer for ALZ Resource Graph query findings.
.DESCRIPTION
    Converts raw alz-queries wrapper output to v3 FindingRow objects.
    Platform=Azure, EntityType=AzureResource.
    ALZ findings have: Id, Title, Category, Severity, Compliant, Detail, ResourceId, LearnMoreUrl.
#>

[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 Convert-ToTagSlug {
    param ([string]$Value)
    if ([string]::IsNullOrWhiteSpace($Value)) { return '' }
    $slug = $Value.Trim().ToLowerInvariant() -replace '[^a-z0-9]+', '-'
    return $slug.Trim('-')
}

function Resolve-Pillar {
    param (
        [string]$Category,
        [string]$Subcategory,
        [string]$Title
    )

    $seed = "$Category $Subcategory $Title".ToLowerInvariant()
    if ($seed -match 'identity|access|entra|rbac|mfa|pim|security|network|defender|key vault|firewall|ddos') {
        return 'Security'
    }
    return 'OperationalExcellence'
}

function Resolve-Impact {
    param ([string]$Severity)
    switch ($Severity) {
        'Critical' { return 'High' }
        'High' { return 'High' }
        'Medium' { return 'Medium' }
        'Low' { return 'Low' }
        default { return 'Low' }
    }
}

function Resolve-Effort {
    param ([string]$Severity)
    switch ($Severity) {
        'Critical' { return 'High' }
        'High' { return 'High' }
        'Medium' { return 'Medium' }
        default { return 'Low' }
    }
}

function Normalize-AlzQueries {
    [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 = ''
        if ($finding.PSObject.Properties['ResourceId'] -and $finding.ResourceId) {
            $rawId = [string]$finding.ResourceId
        }

        $subId = ''
        $rg = ''
        $canonicalId = ''

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

        # Use query GUID as fallback entity ID
        $findingId = if ($finding.PSObject.Properties['Id'] -and $finding.Id) {
            [string]$finding.Id
        } else {
            [guid]::NewGuid().ToString()
        }
        if (-not $canonicalId) {
            $fallbackArmId = "/subscriptions/00000000-0000-0000-0000-000000000000/providers/microsoft.resourcegraph/alzqueries/$findingId"
            $canonicalId = (ConvertTo-CanonicalEntityId -RawId $fallbackArmId -EntityType 'AzureResource').CanonicalId
        }

        $title = if ($finding.PSObject.Properties['Title'] -and $finding.Title) { $finding.Title } else { 'Unknown' }
        $category = if ($finding.PSObject.Properties['Category'] -and $finding.Category) { $finding.Category } else { 'ALZ' }
        $subcategory = [string](Get-PropertyValue $finding 'Subcategory' '')
        $severity = if ($finding.PSObject.Properties['Severity'] -and $finding.Severity) { $finding.Severity } else { 'Medium' }

        # Validate severity is in the allowed set
        if ($severity -notin @('Critical', 'High', 'Medium', 'Low', 'Info')) {
            $severity = switch -Regex ($severity.ToString().ToLowerInvariant()) {
                'critical'         { 'Critical' }
                'high'             { 'High' }
                'medium|moderate'  { 'Medium' }
                'low'              { 'Low' }
                'info'             { 'Info' }
                default            { 'Medium' }
            }
        }

        $compliant = if ($finding.PSObject.Properties['Compliant']) { [bool]$finding.Compliant } else { $true }
        $detail = if ($finding.PSObject.Properties['Detail'] -and $finding.Detail) { $finding.Detail } else { '' }
        $learnMore = if ($finding.PSObject.Properties['LearnMoreUrl'] -and $finding.LearnMoreUrl) { $finding.LearnMoreUrl } else { '' }
        $remediation = [string](Get-PropertyValue $finding 'Remediation' '')
        $pillar = Resolve-Pillar -Category $category -Subcategory $subcategory -Title ([string]$title)
        $impact = Resolve-Impact -Severity $severity
        $effort = Resolve-Effort -Severity $severity
        $deepLinkUrl = "https://github.com/martinopedal/alz-graph-queries/search?q=$findingId&type=code"
        $frameworks = @(@{ kind = 'ALZ'; controlId = $findingId })
        $baselineTags = [System.Collections.Generic.List[string]]::new()
        $categorySlug = Convert-ToTagSlug -Value $category
        if ($categorySlug) { $baselineTags.Add("alz-category:$categorySlug") | Out-Null }
        $subCategorySlug = Convert-ToTagSlug -Value $subcategory
        if ($subCategorySlug) { $baselineTags.Add("alz-subcategory:$subCategorySlug") | Out-Null }
        $evidenceUris = [System.Collections.Generic.List[string]]::new()
        if (-not [string]::IsNullOrWhiteSpace($learnMore)) { $evidenceUris.Add($learnMore) | Out-Null }
        $evidenceUris.Add($deepLinkUrl) | Out-Null
        $entityRefs = [System.Collections.Generic.List[string]]::new()
        $entityRefs.Add([string]$canonicalId) | Out-Null
        if (-not [string]::IsNullOrWhiteSpace($rawId)) { $entityRefs.Add($rawId.ToLowerInvariant()) | Out-Null }
        $toolVersion = [string](Get-PropertyValue $finding 'ToolVersion' (Get-PropertyValue $ToolResult 'ToolVersion' ''))
        $remediationSnippets = @()
        if (-not [string]::IsNullOrWhiteSpace($remediation)) {
            $remediationSnippets = @(@{ language = 'text'; code = $remediation.Trim() })
        } elseif (-not [string]::IsNullOrWhiteSpace($detail)) {
            $remediationSnippets = @(@{ language = 'text'; code = $detail.Trim() })
        }

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

    return @($normalized)
}