modules/normalizers/Normalize-PSRule.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Normalizer for PSRule for Azure findings.
.DESCRIPTION
    Converts PSRule wrapper output to v3 FindingRow objects.
    Platform=Azure, EntityType=AzureResource.
    The wrapper already standardises to v1 fields: Title, Category, Compliant,
    Severity, Detail, Remediation, ResourceId, LearnMoreUrl.
#>

[CmdletBinding()]
param ()

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

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

    function Get-RemediationSnippets {
        param([string] $Recommendation)
        if ([string]::IsNullOrWhiteSpace($Recommendation)) {
            return @()
        }

        $snippets = [System.Collections.Generic.List[hashtable]]::new()
        $matches = [regex]::Matches($Recommendation, '(?ms)```(?<language>[^\r\n`]*)\r?\n(?<code>.*?)```')
        foreach ($match in $matches) {
            $code = [string]$match.Groups['code'].Value
            if ([string]::IsNullOrWhiteSpace($code)) { continue }
            $language = [string]$match.Groups['language'].Value
            if ([string]::IsNullOrWhiteSpace($language)) { $language = 'text' }
            $snippets.Add(@{
                    language = $language.Trim().ToLowerInvariant()
                    code     = $code.Trim()
                }) | Out-Null
        }

        if ($snippets.Count -eq 0) {
            $snippets.Add(@{
                    language = 'text'
                    code     = $Recommendation.Trim()
                }) | Out-Null
        }

        return @($snippets)
    }

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

        $findingId = if ($finding.PSObject.Properties['Id'] -and $finding.Id) {
            [string]$finding.Id
        } else {
            [guid]::NewGuid().ToString()
        }
        if (-not $canonicalId) {
            $canonicalId = "psrule/$findingId"
        }

        $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 { 'PSRule' }

        $rawSev = if ($finding.PSObject.Properties['Severity'] -and $finding.Severity) { $finding.Severity } else { 'Medium' }
        $severity = switch -Regex ($rawSev.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 { '' }
        $remediation = if ($finding.PSObject.Properties['Remediation'] -and $finding.Remediation) { $finding.Remediation } else { '' }
        $learnMore = if ($finding.PSObject.Properties['LearnMoreUrl'] -and $finding.LearnMoreUrl) { $finding.LearnMoreUrl } else { '' }
        $ruleId = if ($finding.PSObject.Properties['RuleId'] -and $finding.RuleId) { [string]$finding.RuleId } else { $category }
        $pillar = if ($finding.PSObject.Properties['Pillar'] -and $finding.Pillar) { [string]$finding.Pillar } else { '' }
        $deepLinkUrl = if ($finding.PSObject.Properties['DeepLinkUrl'] -and $finding.DeepLinkUrl) { [string]$finding.DeepLinkUrl } else { '' }
        $frameworks = if ($finding.PSObject.Properties['Frameworks'] -and $finding.Frameworks) { @($finding.Frameworks) } else { @() }
        $baselineTags = if ($finding.PSObject.Properties['BaselineTags'] -and $finding.BaselineTags) { @($finding.BaselineTags) } else { @() }
        $toolVersion = if ($finding.PSObject.Properties['ToolVersion'] -and $finding.ToolVersion) { [string]$finding.ToolVersion } else { '' }
        $remediationSnippets = Get-RemediationSnippets -Recommendation $remediation

        # Track D enrichment (#432b): derive Impact/Effort, surface evidence URIs,
        # pass through MITRE + ScoreDelta, and seed EntityRefs with subscription scope.
        $impact = if ($finding.PSObject.Properties['Impact'] -and $finding.Impact) { [string]$finding.Impact } else {
            switch ($severity) { 'Critical' { 'High' } 'High' { 'High' } 'Medium' { 'Medium' } default { 'Low' } }
        }
        $effort = if ($finding.PSObject.Properties['Effort'] -and $finding.Effort) { [string]$finding.Effort } else {
            switch ($severity) { 'Critical' { 'High' } 'High' { 'Medium' } 'Medium' { 'Medium' } default { 'Low' } }
        }
        $evidenceUris = [System.Collections.Generic.List[string]]::new()
        if ($finding.PSObject.Properties['EvidenceUris'] -and $finding.EvidenceUris) {
            foreach ($u in @($finding.EvidenceUris)) { if (-not [string]::IsNullOrWhiteSpace([string]$u)) { $evidenceUris.Add([string]$u) | Out-Null } }
        }
        foreach ($u in @($learnMore, $deepLinkUrl)) {
            if (-not [string]::IsNullOrWhiteSpace([string]$u) -and ($evidenceUris -notcontains [string]$u)) {
                $evidenceUris.Add([string]$u) | Out-Null
            }
        }
        $mitreTactics = if ($finding.PSObject.Properties['MitreTactics'] -and $finding.MitreTactics) { @([string[]]$finding.MitreTactics) } else { @() }
        $mitreTechniques = if ($finding.PSObject.Properties['MitreTechniques'] -and $finding.MitreTechniques) { @([string[]]$finding.MitreTechniques) } else { @() }
        $scoreDelta = $null
        if ($finding.PSObject.Properties['ScoreDelta'] -and $null -ne $finding.ScoreDelta) {
            try { $scoreDelta = [double]$finding.ScoreDelta } catch { $scoreDelta = $null }
        }
        $entityRefs = [System.Collections.Generic.List[string]]::new()
        if ($finding.PSObject.Properties['EntityRefs'] -and $finding.EntityRefs) {
            foreach ($r in @($finding.EntityRefs)) { if (-not [string]::IsNullOrWhiteSpace([string]$r)) { $entityRefs.Add([string]$r) | Out-Null } }
        }
        if ($subId) {
            try {
                $subRef = (ConvertTo-CanonicalEntityId -RawId $subId -EntityType 'Subscription').CanonicalId
                if ($subRef -and $entityRefs -notcontains $subRef) { $entityRefs.Add($subRef) | Out-Null }
            } catch { } # best-effort: malformed subscriptionId; skip enrichment, keep raw finding
        }

        $row = New-FindingRow -Id $findingId `
            -Source 'psrule'-EntityId $canonicalId -EntityType 'AzureResource' `
            -Title $title -RuleId $ruleId -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 $pillar -DeepLinkUrl $deepLinkUrl `
            -RemediationSnippets $remediationSnippets -BaselineTags $baselineTags `
            -Impact $impact -Effort $effort -EvidenceUris @($evidenceUris) `
            -MitreTactics @($mitreTactics) -MitreTechniques @($mitreTechniques) `
            -ScoreDelta $scoreDelta -EntityRefs @($entityRefs) `
            -ToolVersion $toolVersion
        # Skip null rows (validation failed)
        if ($null -ne $row) {
            $normalized.Add($row)
        }
    }

    return @($normalized)
}