modules/normalizers/Normalize-IaCBicep.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Normalizer for Bicep IaC validation findings.
.DESCRIPTION
    Converts raw Bicep wrapper output to v2 FindingRow objects.
    Platform=GitHub, EntityType=Repository.
#>

[CmdletBinding()]
param ()

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

function Convert-ToBicepPathSlug {
    param ([string] $PathValue)
    if ([string]::IsNullOrWhiteSpace($PathValue)) { return '' }
    return ($PathValue.Trim() -replace '\\', '/' -replace '^\./', '').ToLowerInvariant()
}

function Resolve-BicepRuleId {
    param ([object] $Finding)
    if ($Finding.PSObject.Properties['RuleId'] -and -not [string]::IsNullOrWhiteSpace([string]$Finding.RuleId)) {
        return [string]$Finding.RuleId
    }

    $detail = ''
    if ($Finding.PSObject.Properties['Detail'] -and $null -ne $Finding.Detail) { $detail = [string]$Finding.Detail }
    $match = [regex]::Match($detail, '\b(BCP\d{3}|AZR-[A-Z0-9-]+)\b', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
    if ($match.Success) { return $match.Groups[1].Value.ToUpperInvariant() }
    return 'BICEP-UNKNOWN'
}

function Resolve-BicepLevel {
    param ([object] $Finding)
    if ($Finding.PSObject.Properties['Level'] -and -not [string]::IsNullOrWhiteSpace([string]$Finding.Level)) {
        return [string]$Finding.Level
    }

    $detail = ''
    if ($Finding.PSObject.Properties['Detail'] -and $null -ne $Finding.Detail) { $detail = [string]$Finding.Detail }
    if ($detail -match '(?i)\berror\b') { return 'Error' }
    if ($detail -match '(?i)\bwarning\b') { return 'Warning' }
    if ($detail -match '(?i)\binfo(?:rmational)?\b') { return 'Info' }
    return ''
}

function Resolve-BicepSeverity {
    param ([string] $RawSeverity, [string] $Level)
    $sev = if (-not [string]::IsNullOrWhiteSpace($Level)) { $Level } else { $RawSeverity }
    switch -Regex ($sev.ToLowerInvariant()) {
        'critical'         { 'Critical' }
        '^error$|high'     { 'High' }
        '^warning$|medium|moderate' { 'Medium' }
        '^info$|informational|low' { 'Low' }
        default            { 'Info' }
    }
}

function Resolve-BicepPillar {
    param (
        [string] $RuleId,
        [string] $Category,
        [string] $Title,
        [string] $Detail
    )

    $haystack = "$RuleId $Category $Title $Detail".ToLowerInvariant()
    if ($haystack -match 'cost|sku|pricing|budget|reserved|retention|size') { return 'Cost Optimization' }
    if ($haystack -match 'performance|throughput|latency|cache|concurrency') { return 'Performance Efficiency' }
    if ($haystack -match 'reliability|availability|zone|backup|dr|failover|region') { return 'Reliability' }
    if ($haystack -match 'operation|operational|diagnostic|logging|monitor|tag|governance|policy') { return 'Operational Excellence' }
    if ($haystack -match 'security|secret|password|keyvault|identity|rbac|tls|encrypt|defender') { return 'Security' }
    return 'Security'
}

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

function Resolve-BicepFrameworks {
    param ([string] $Pillar)
    $frameworks = [System.Collections.Generic.List[hashtable]]::new()
    $frameworks.Add(@{ kind = 'Azure WAF'; controlId = $Pillar }) | Out-Null
    switch ($Pillar) {
        'Security' {
            $frameworks.Add(@{ kind = 'CIS Azure'; controlId = '3.1' }) | Out-Null
            $frameworks.Add(@{ kind = 'Azure Security Benchmark'; controlId = 'NS-1' }) | Out-Null
        }
        'Reliability' {
            $frameworks.Add(@{ kind = 'CIS Azure'; controlId = '4.1' }) | Out-Null
            $frameworks.Add(@{ kind = 'Azure Security Benchmark'; controlId = 'RE-1' }) | Out-Null
        }
        'Cost Optimization' {
            $frameworks.Add(@{ kind = 'CIS Azure'; controlId = '5.1' }) | Out-Null
            $frameworks.Add(@{ kind = 'Azure Security Benchmark'; controlId = 'PV-5' }) | Out-Null
        }
        'Performance Efficiency' {
            $frameworks.Add(@{ kind = 'CIS Azure'; controlId = '6.1' }) | Out-Null
            $frameworks.Add(@{ kind = 'Azure Security Benchmark'; controlId = 'PE-1' }) | Out-Null
        }
        'Operational Excellence' {
            $frameworks.Add(@{ kind = 'CIS Azure'; controlId = '2.1' }) | Out-Null
            $frameworks.Add(@{ kind = 'Azure Security Benchmark'; controlId = 'OE-1' }) | Out-Null
        }
    }
    return @($frameworks)
}

function Resolve-BicepDeepLinkUrl {
    param (
        [string] $RuleId,
        [string] $LearnMoreUrl
    )

    if ($RuleId -match '^[a-z][a-z0-9\-]+$') {
        return "https://learn.microsoft.com/azure/azure-resource-manager/bicep/linter-rule-$($RuleId.ToLowerInvariant())"
    }
    if ($RuleId -match '^AZR-[A-Z0-9-]+$') {
        return "https://azure.github.io/PSRule.Rules.Azure/en/rules/$RuleId/"
    }
    if (-not [string]::IsNullOrWhiteSpace($LearnMoreUrl)) { return $LearnMoreUrl }
    return 'https://learn.microsoft.com/azure/azure-resource-manager/bicep/linter'
}

function Resolve-BicepEvidenceUris {
    param (
        [string] $PathSlug,
        [string] $Detail,
        [string] $RepositoryUrl,
        [string] $RepositoryRef
    )

    $lineAnchor = ''
    $lineMatch = [regex]::Match($Detail, '\((?<line>\d+)(?:,\d+)?\)')
    if ($lineMatch.Success) { $lineAnchor = "#L$($lineMatch.Groups['line'].Value)" }

    $uris = [System.Collections.Generic.List[string]]::new()
    if (-not [string]::IsNullOrWhiteSpace($PathSlug)) {
        $uris.Add("file:///$PathSlug$lineAnchor") | Out-Null
        if (-not [string]::IsNullOrWhiteSpace($RepositoryUrl)) {
            $ref = if ([string]::IsNullOrWhiteSpace($RepositoryRef)) { 'main' } else { $RepositoryRef }
            $repo = $RepositoryUrl.TrimEnd('/')
            $uris.Add("$repo/blob/$ref/$PathSlug$lineAnchor") | Out-Null
        }
    }
    return @($uris)
}

function Resolve-BicepRemediationSnippets {
    param (
        [string] $RuleId,
        [string] $Remediation
    )

    $after = if ([string]::IsNullOrWhiteSpace($Remediation)) { '// apply recommended Bicep fix' } else { $Remediation.Trim() }
    return @(
        @{
            language = 'bicep'
            before   = "// noncompliant: $RuleId"
            after    = "// compliant: $after"
        }
    )
}

function Resolve-BicepScoreDelta {
    param ([string] $Severity)
    switch ($Severity) {
        'Critical' { return [double]4.0 }
        'High' { return [double]3.0 }
        'Medium' { return [double]2.0 }
        'Low' { return [double]1.0 }
        default { return [double]0.0 }
    }
}

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

    function Add-BicepTrackAEdges {
        param([object] $Candidate)
        if ($null -eq $EdgeCollector) { return }
        if ($null -eq $Candidate -or -not $Candidate.PSObject.Properties['AttackPathEdges']) { return }
        $allowedRelations = @('TriggeredBy', 'AuthenticatesAs', 'DeploysTo', 'UsesSecret', 'HasFederatedCredential', 'Declares')
        foreach ($edgeHint in @($Candidate.AttackPathEdges)) {
            if ($null -eq $edgeHint) { continue }
            $source = if ($edgeHint.PSObject.Properties['Source']) { [string]$edgeHint.Source } else { '' }
            $target = if ($edgeHint.PSObject.Properties['Target']) { [string]$edgeHint.Target } else { '' }
            $relation = if ($edgeHint.PSObject.Properties['Relation']) { [string]$edgeHint.Relation } else { '' }
            if ([string]::IsNullOrWhiteSpace($source) -or [string]::IsNullOrWhiteSpace($target)) { continue }
            if ($relation -notin $allowedRelations) { continue }
            $edge = New-Edge -Source $source -Target $target -Relation $relation -Confidence 'Likely' -Platform 'IaC' -DiscoveredBy 'bicep-iac'
            if ($null -ne $edge) { $EdgeCollector.Add($edge) | Out-Null }
        }
    }

    foreach ($finding in $ToolResult.Findings) {
        Add-BicepTrackAEdges -Candidate $finding
        $rawId = ''
        if ($finding.PSObject.Properties['ResourceId'] -and $finding.ResourceId) {
            $rawId = [string]$finding.ResourceId
        }

        $pathSlug = Convert-ToBicepPathSlug -PathValue $rawId
        $canonicalId = ''
        if ($rawId -and $rawId -match '^/subscriptions/') {
            try {
                $canonicalId = (ConvertTo-CanonicalEntityId -RawId $rawId -EntityType 'AzureResource').CanonicalId
            } catch {
                $canonicalId = $rawId.ToLowerInvariant()
            }
        } elseif (-not [string]::IsNullOrWhiteSpace($pathSlug)) {
            $slugToken = $pathSlug -replace '[^a-z0-9/\-]', '-' -replace '/', '-'
            $syntheticArmId = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/iac-bicep/providers/Microsoft.Resources/deployments/$slugToken"
            $canonicalId = (ConvertTo-CanonicalEntityId -RawId $syntheticArmId -EntityType 'AzureResource').CanonicalId
        }

        $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/resourceGroups/iac-bicep/providers/Microsoft.Resources/deployments/$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 { 'IaC Validation' }

        $level = Resolve-BicepLevel -Finding $finding
        $rawSev = if ($finding.PSObject.Properties['Severity'] -and $finding.Severity) { [string]$finding.Severity } else { 'Info' }
        $severity = Resolve-BicepSeverity -RawSeverity $rawSev -Level $level

        $compliant = if ($finding.PSObject.Properties['Compliant']) { [bool]$finding.Compliant } else { $false }
        $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 = Resolve-BicepRuleId -Finding $finding
        $pillar = Resolve-BicepPillar -RuleId $ruleId -Category ([string]$category) -Title ([string]$title) -Detail ([string]$detail)
        $impact = Resolve-BicepImpact -Severity $severity
        $frameworks = Resolve-BicepFrameworks -Pillar $pillar
        $deepLinkUrl = Resolve-BicepDeepLinkUrl -RuleId $ruleId -LearnMoreUrl $learnMore
        $baselineLevel = if ([string]::IsNullOrWhiteSpace($level)) { $severity } else { $level }
        $baselineTags = @(
            "bicep:rule:$ruleId",
            "bicep:level:$baselineLevel",
            "bicep:category:$category"
        )
        $repositoryUrl = if ($ToolResult.PSObject.Properties['RepositoryUrl'] -and $ToolResult.RepositoryUrl) { [string]$ToolResult.RepositoryUrl } else { '' }
        $repositoryRef = if ($ToolResult.PSObject.Properties['RepositoryRef'] -and $ToolResult.RepositoryRef) { [string]$ToolResult.RepositoryRef } else { 'main' }
        $evidenceUris = Resolve-BicepEvidenceUris -PathSlug $pathSlug -Detail ([string]$detail) -RepositoryUrl $repositoryUrl -RepositoryRef $repositoryRef
        $entityRefs = @("iac:bicep:$pathSlug")
        if (-not [string]::IsNullOrWhiteSpace($pathSlug)) {
            $pathDir = Split-Path -Path $pathSlug -Parent
            if (-not [string]::IsNullOrWhiteSpace($pathDir)) { $entityRefs += "iac:module:$pathDir" }
        }
        $toolVersion = if ($finding.PSObject.Properties['ToolVersion'] -and $finding.ToolVersion) {
            [string]$finding.ToolVersion
        } elseif ($ToolResult.PSObject.Properties['ToolVersion'] -and $ToolResult.ToolVersion) {
            [string]$ToolResult.ToolVersion
        } else {
            ''
        }
        $remediationSnippets = Resolve-BicepRemediationSnippets -RuleId $ruleId -Remediation ([string]$remediation)
        $scoreDelta = Resolve-BicepScoreDelta -Severity $severity

        $row = New-FindingRow -Id $findingId `
            -Source 'bicep-iac' -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 ?? '') -RuleId $ruleId `
            -Pillar $pillar -Impact $impact -Effort 'Low' -DeepLinkUrl $deepLinkUrl `
            -Frameworks $frameworks -RemediationSnippets $remediationSnippets `
            -EvidenceUris $evidenceUris -BaselineTags $baselineTags `
            -ScoreDelta $scoreDelta -EntityRefs $entityRefs -ToolVersion $toolVersion
        if ($null -ne $row) {
            $normalized.Add($row)
        }
    }

    return @($normalized)
}