modules/normalizers/Normalize-Powerpipe.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Normalizer for Powerpipe control-pack findings.
.DESCRIPTION
    Converts Powerpipe v1 wrapper output to schema v2.2 FindingRows, including
    additive framework and baseline metadata fields.
#>

[CmdletBinding()]
param ()

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

function Get-PropertyValue {
    param ([object]$Obj, [string[]]$Names, [object]$Default = $null)
    if ($null -eq $Obj) { return $Default }
    foreach ($n in $Names) {
        $p = $Obj.PSObject.Properties[$n]
        if ($null -ne $p -and $null -ne $p.Value) { return $p.Value }
    }
    return $Default
}

function Get-StringArray {
    param([object]$Value)
    if ($null -eq $Value) { return @() }
    if ($Value -is [string]) {
        if ([string]::IsNullOrWhiteSpace($Value)) { return @() }
        return @($Value -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
    }
    return @($Value | ForEach-Object { "$_".Trim() } | Where-Object { $_ })
}

function Resolve-Severity {
    param([string]$Raw)
    if ([string]::IsNullOrWhiteSpace($Raw)) { return 'Medium' }
    switch -Regex ($Raw.ToLowerInvariant()) {
        'critical'         { 'Critical' }
        'high|alarm'       { 'High' }
        'medium|moderate'  { 'Medium' }
        'low'              { 'Low' }
        'info|ok|pass'     { 'Info' }
        default            { 'Medium' }
    }
}

function Resolve-Compliant {
    param([object]$Finding)
    $explicit = Get-PropertyValue -Obj $Finding -Names @('Compliant', 'compliant') -Default $null
    if ($null -ne $explicit) { return [bool]$explicit }
    $status = [string](Get-PropertyValue -Obj $Finding -Names @('Status', 'status') -Default '')
    return ($status -match '^(ok|pass|passed|compliant|skip|skipped)$')
}

function Resolve-Pillar {
    param([string]$Raw)
    if ([string]::IsNullOrWhiteSpace($Raw)) { return '' }
    $value = $Raw.ToLowerInvariant()
    if ($value -match 'security') { return 'Security' }
    if ($value -match 'cost|finops') { return 'Cost Optimization' }
    if ($value -match 'reliability|resilien') { return 'Reliability' }
    if ($value -match 'operat') { return 'Operational Excellence' }
    if ($value -match 'performance') { return 'Performance Efficiency' }
    return $Raw
}

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

    $list = [System.Collections.Generic.List[hashtable]]::new()
    $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)

    foreach ($framework in @(Get-PropertyValue -Obj $Finding -Names @('Frameworks', 'frameworks') -Default @())) {
        if ($null -eq $framework) { continue }
        $kind = [string](Get-PropertyValue -Obj $framework -Names @('kind', 'Kind', 'name', 'Name') -Default '')
        $controlId = [string](Get-PropertyValue -Obj $framework -Names @('controlId', 'ControlId', 'id', 'Id') -Default '')
        if ([string]::IsNullOrWhiteSpace($kind) -or [string]::IsNullOrWhiteSpace($controlId)) { continue }
        $key = "$kind|$controlId"
        if ($seen.Add($key)) { $list.Add(@{ kind = $kind; controlId = $controlId }) | Out-Null }
    }

    $excludedTagKeys = @(
        'pillar', 'category', 'impact', 'effort',
        'deep_link_url', 'deeplinkurl', 'documentation_url', 'url', 'doc_url',
        'remediation', 'remediation_doc',
        'evidence_uri', 'evidence_uris',
        'baseline', 'baseline_tags', 'release',
        'mitre_tactics', 'mitre_techniques'
    )

    foreach ($tp in @($Tags.PSObject.Properties)) {
        $tagKey = [string]$tp.Name
        if (-not $tagKey) { continue }
        if ($excludedTagKeys -contains $tagKey.ToLowerInvariant()) { continue }
        foreach ($ref in @(Get-StringArray -Value $tp.Value)) {
            $key = "$tagKey|$ref"
            if ($seen.Add($key)) {
                $list.Add(@{ kind = $tagKey.ToUpperInvariant(); controlId = $ref }) | Out-Null
            }
        }
    }

    return @($list)
}

function Normalize-Powerpipe {
    [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()
    $toolVersion = [string](Get-PropertyValue -Obj $ToolResult -Names @('ToolVersion', 'toolVersion') -Default '')
    $defaultSub = [string](Get-PropertyValue -Obj $ToolResult -Names @('Subscription', 'SubscriptionId') -Default '')

    foreach ($finding in $ToolResult.Findings) {
        $rawId = [string](Get-PropertyValue -Obj $finding -Names @('ResourceId', 'resourceId') -Default '')
        $subId = $defaultSub
        $rg = ''
        $canonicalId = ''

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

        $controlId = [string](Get-PropertyValue -Obj $finding -Names @('ControlId', 'controlId', 'RuleId', 'ruleId', 'Id', 'id') -Default ([guid]::NewGuid().ToString()))
        if (-not $canonicalId) {
            $fallbackSub = if ($subId -match '^[0-9a-fA-F-]{36}$') { $subId.ToLowerInvariant() } else { '00000000-0000-0000-0000-000000000000' }
            $fallbackArm = "/subscriptions/$fallbackSub/providers/microsoft.security/powerpipeControls/$($controlId.ToLowerInvariant())"
            $canonicalId = (ConvertTo-CanonicalEntityId -RawId $fallbackArm -EntityType 'AzureResource').CanonicalId
        }

        $tags = Get-PropertyValue -Obj $finding -Names @('Tags', 'tags') -Default ([pscustomobject]@{})
        if ($null -eq $tags) { $tags = [pscustomobject]@{} }

        $title = [string](Get-PropertyValue -Obj $finding -Names @('Title', 'title', 'ControlTitle') -Default $controlId)
        $category = [string](Get-PropertyValue -Obj $finding -Names @('Category', 'category') -Default (Get-PropertyValue -Obj $tags -Names @('category') -Default 'Compliance'))
        $severity = Resolve-Severity -Raw ([string](Get-PropertyValue -Obj $finding -Names @('Severity', 'severity', 'Status', 'status') -Default 'Medium'))
        $compliant = Resolve-Compliant -Finding $finding

        $detail = [string](Get-PropertyValue -Obj $finding -Names @('Detail', 'detail', 'Description', 'description') -Default '')
        $remediation = [string](Get-PropertyValue -Obj $finding -Names @('Remediation', 'remediation', 'remediation_doc') -Default (Get-PropertyValue -Obj $tags -Names @('remediation_doc') -Default ''))
        $learnMore = [string](Get-PropertyValue -Obj $finding -Names @('LearnMoreUrl', 'learnMoreUrl', 'DocumentationUrl', 'documentation_url') -Default '')
        $deepLink = [string](Get-PropertyValue -Obj $finding -Names @('DeepLinkUrl', 'deepLinkUrl') -Default (Get-PropertyValue -Obj $tags -Names @('deep_link_url', 'documentation_url') -Default $learnMore))

        $frameworks = Resolve-Frameworks -Finding $finding -Tags $tags
        $evidenceUris = @(
            (Get-StringArray -Value (Get-PropertyValue -Obj $finding -Names @('EvidenceUris', 'evidenceUris') -Default @())) +
            (Get-StringArray -Value (Get-PropertyValue -Obj $tags -Names @('evidence_uri', 'evidence_uris') -Default @()))
        ) | Where-Object { $_ } | Select-Object -Unique

        $baselineTags = @(
            (Get-StringArray -Value (Get-PropertyValue -Obj $finding -Names @('BaselineTags', 'baselineTags') -Default @())) +
            (Get-StringArray -Value (Get-PropertyValue -Obj $tags -Names @('baseline_tags') -Default @()))
        )
        $baseline = [string](Get-PropertyValue -Obj $tags -Names @('baseline') -Default '')
        if ($baseline) { $baselineTags += "baseline:$baseline" }
        $release = [string](Get-PropertyValue -Obj $tags -Names @('release') -Default '')
        if ($release) { $baselineTags += "release:$release" }
        $baselineTags = @($baselineTags | Where-Object { $_ } | Select-Object -Unique)

        $snippetText = [string](Get-PropertyValue -Obj $finding -Names @('RemediationDoc', 'remediation_doc') -Default (Get-PropertyValue -Obj $tags -Names @('remediation_doc') -Default ''))
        $snippets = @()
        if ($snippetText) {
            $snippets = @(@{ title = 'Powerpipe remediation'; content = $snippetText; language = 'text' })
        }

        $mitreTactics = @(
            (Get-StringArray -Value (Get-PropertyValue -Obj $finding -Names @('MitreTactics', 'mitreTactics') -Default @())) +
            (Get-StringArray -Value (Get-PropertyValue -Obj $tags -Names @('mitre_tactics') -Default @()))
        ) | Where-Object { $_ } | Select-Object -Unique
        $mitreTechniques = @(
            (Get-StringArray -Value (Get-PropertyValue -Obj $finding -Names @('MitreTechniques', 'mitreTechniques') -Default @())) +
            (Get-StringArray -Value (Get-PropertyValue -Obj $tags -Names @('mitre_techniques') -Default @()))
        ) | Where-Object { $_ } | Select-Object -Unique

        $findingVersion = [string](Get-PropertyValue -Obj $finding -Names @('ToolVersion', 'toolVersion') -Default $toolVersion)
        $pillarRaw = [string](Get-PropertyValue -Obj $finding -Names @('Pillar', 'pillar') -Default (Get-PropertyValue -Obj $tags -Names @('pillar') -Default $category))

        $row = New-FindingRow -Id ([string](Get-PropertyValue -Obj $finding -Names @('Id', 'id') -Default ([guid]::NewGuid().ToString()))) `
            -Source 'powerpipe' -EntityId $canonicalId -EntityType 'AzureResource' `
            -RuleId $controlId -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 `
            -Frameworks $frameworks `
            -Pillar (Resolve-Pillar -Raw $pillarRaw) `
            -Impact ([string](Get-PropertyValue -Obj $finding -Names @('Impact', 'impact') -Default (Get-PropertyValue -Obj $tags -Names @('impact') -Default ''))) `
            -Effort ([string](Get-PropertyValue -Obj $finding -Names @('Effort', 'effort') -Default (Get-PropertyValue -Obj $tags -Names @('effort') -Default ''))) `
            -DeepLinkUrl $deepLink `
            -RemediationSnippets @($snippets) `
            -EvidenceUris $evidenceUris `
            -BaselineTags $baselineTags `
            -MitreTactics $mitreTactics `
            -MitreTechniques $mitreTechniques `
            -EntityRefs (Get-StringArray -Value (Get-PropertyValue -Obj $finding -Names @('EntityRefs', 'entityRefs') -Default @())) `
            -ToolVersion $findingVersion

        if ($null -ne $row) {
            $normalized.Add($row)
        }
    }

    return @($normalized)
}