modules/normalizers/Normalize-IaCTerraform.ps1

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

[CmdletBinding()]
param ()

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

function Convert-ToStringArray {
    param ([object]$Value)
    $items = [System.Collections.Generic.List[string]]::new()
    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-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
        }
    }
    return @($items)
}

function Resolve-TerraformRuleId {
    param([object]$Finding)
    if ($Finding.PSObject.Properties['RuleId'] -and $Finding.RuleId) { return [string]$Finding.RuleId }
    if ($Finding.PSObject.Properties['Id'] -and $Finding.Id) { return [string]$Finding.Id }
    if ($Finding.PSObject.Properties['Title'] -and $Finding.Title -and [string]$Finding.Title -match '^(AVD-[A-Z]+-\d+|CKV_[A-Z_0-9]+|TFSEC-[A-Z_0-9-]+)') { return $Matches[1] }
    return 'terraform-validate'
}

function Resolve-TerraformRepositoryEntityId {
    param([object]$ToolResult)
    $candidates = [System.Collections.Generic.List[string]]::new()
    if ($ToolResult.PSObject.Properties['Repository'] -and $ToolResult.Repository) { $candidates.Add([string]$ToolResult.Repository) | Out-Null }
    if ($ToolResult.PSObject.Properties['RemoteUrl'] -and $ToolResult.RemoteUrl) { $candidates.Add([string]$ToolResult.RemoteUrl) | Out-Null }
    if ($ToolResult.PSObject.Properties['SourceRepoUrl'] -and $ToolResult.SourceRepoUrl) { $candidates.Add([string]$ToolResult.SourceRepoUrl) | Out-Null }

    foreach ($candidate in $candidates) {
        if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
        try { return (ConvertTo-CanonicalEntityId -RawId $candidate -EntityType 'Repository').CanonicalId } catch { }
    }
    return 'iac.local/terraform-iac/repository'
}

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

    $tactics = [System.Collections.Generic.List[string]]::new()
    $techniques = [System.Collections.Generic.List[string]]::new()

    if (-not ($Category -match '(?i)security')) {
        return @{
            MitreTactics = @()
            MitreTechniques = @()
        }
    }

    $signal = "$RuleId $Title $Detail".ToLowerInvariant()
    if ($signal -match 'identity|iam|role|permission|privilege|access') {
        $tactics.Add('TA0004') | Out-Null
        $techniques.Add('T1078') | Out-Null
    }
    if ($signal -match 'public|0\.0\.0\.0/0|ingress|network|nsg|firewall|exposed') {
        $tactics.Add('TA0001') | Out-Null
        $techniques.Add('T1190') | Out-Null
    }
    if ($signal -match 'keyvault|key vault|secret|token|credential|purge protection|encryption') {
        $tactics.Add('TA0006') | Out-Null
        $techniques.Add('T1552') | Out-Null
    }

    if ($tactics.Count -eq 0) {
        $tactics.Add('TA0001') | Out-Null
        $techniques.Add('T1190') | Out-Null
    }

    return @{
        MitreTactics = @($tactics | Select-Object -Unique)
        MitreTechniques = @($techniques | Select-Object -Unique)
    }
}

function Normalize-IaCTerraform {
    [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()
    $repositoryEntityId = Resolve-TerraformRepositoryEntityId -ToolResult $ToolResult
    $normalized = [System.Collections.Generic.List[PSCustomObject]]::new()

    function Add-TerraformTrackAEdges {
        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 'terraform-iac'
            if ($null -ne $edge) { $EdgeCollector.Add($edge) | Out-Null }
        }
    }

    foreach ($finding in $ToolResult.Findings) {
        Add-TerraformTrackAEdges -Candidate $finding
        $rawId = if ($finding.PSObject.Properties['ResourceId'] -and $finding.ResourceId) { [string]$finding.ResourceId } else { '' }
        $findingId = if ($finding.PSObject.Properties['Id'] -and $finding.Id) { [string]$finding.Id } else { [guid]::NewGuid().ToString() }
        $title = if ($finding.PSObject.Properties['Title'] -and $finding.Title) { [string]$finding.Title } else { 'Unknown' }
        $category = if ($finding.PSObject.Properties['Category'] -and $finding.Category) { [string]$finding.Category } else { 'IaC Validation' }
        $ruleId = Resolve-TerraformRuleId -Finding $finding

        $rawSev = if ($finding.PSObject.Properties['Severity'] -and $finding.Severity) { [string]$finding.Severity } else { 'medium' }
        $severity = switch -Regex ($rawSev.ToLowerInvariant()) {
            'critical' { 'Critical' }
            'high' { 'High' }
            'medium|moderate' { 'Medium' }
            'low' { 'Low' }
            'info|unknown' { 'Info' }
            default { 'Info' }
        }

        $compliant = if ($finding.PSObject.Properties['Compliant']) { [bool]$finding.Compliant } else { $false }
        $detail = if ($finding.PSObject.Properties['Detail'] -and $finding.Detail) { [string]$finding.Detail } else { '' }
        $remediation = if ($finding.PSObject.Properties['Remediation'] -and $finding.Remediation) { [string]$finding.Remediation } else { '' }
        $learnMore = if ($finding.PSObject.Properties['LearnMoreUrl'] -and $finding.LearnMoreUrl) { [string]$finding.LearnMoreUrl } else { '' }
        $pillar = if ($finding.PSObject.Properties['Pillar'] -and $finding.Pillar) { [string]$finding.Pillar } else { 'Security' }
        $pillar = switch -Regex ($pillar.Trim()) {
            '^(?i)operations?|operational.?excellence$' { 'OperationalExcellence' }
            '^(?i)cost|costoptimization$' { 'CostOptimization' }
            '^(?i)performance|performanceefficiency$' { 'PerformanceEfficiency' }
            '^(?i)reliability$' { 'Reliability' }
            default { 'Security' }
        }
        $deepLinkUrl = if ($finding.PSObject.Properties['DeepLinkUrl'] -and $finding.DeepLinkUrl) { [string]$finding.DeepLinkUrl } else { $learnMore }
        $impact = if ($finding.PSObject.Properties['Impact'] -and $finding.Impact) { [string]$finding.Impact } else { '' }
        $effort = if ($finding.PSObject.Properties['Effort'] -and $finding.Effort) { [string]$finding.Effort } else { '' }
        $toolVersion = if ($finding.PSObject.Properties['ToolVersion'] -and $finding.ToolVersion) { [string]$finding.ToolVersion } elseif ($ToolResult.PSObject.Properties['ToolVersion']) { [string]$ToolResult.ToolVersion } else { '' }

        $rawFrameworks = if ($finding.PSObject.Properties['Frameworks']) { $finding.Frameworks } else { @() }
        $frameworks = Merge-FrameworksUnion -Existing @() -Incoming @(Convert-ToHashtableArray $rawFrameworks)
        $rawBaselineTags = if ($finding.PSObject.Properties['BaselineTags']) { $finding.BaselineTags } else { @() }
        $baselineTags = Merge-BaselineTagsUnion -Existing @() -Incoming @(Convert-ToStringArray $rawBaselineTags)
        if (@($baselineTags).Count -eq 0) {
            $toolLabel = 'trivy'
            if ($ruleId -eq 'terraform-validate' -or $ruleId -eq 'terraform-init') { $toolLabel = 'terraform' }
            $baselineTags = @("terraform:rule:$($ruleId.ToLowerInvariant())", 'terraform:provider:azurerm', "terraform:tool:$toolLabel")
        }

        $rawEvidenceUris = if ($finding.PSObject.Properties['EvidenceUris']) { $finding.EvidenceUris } else { @() }
        $evidenceUris = Convert-ToStringArray $rawEvidenceUris
        $rawEntityRefs = if ($finding.PSObject.Properties['EntityRefs']) { $finding.EntityRefs } else { @() }
        $entityRefs = Convert-ToStringArray $rawEntityRefs
        $resourceAddress = if ($finding.PSObject.Properties['ResourceAddress'] -and $finding.ResourceAddress) { [string]$finding.ResourceAddress } else { '' }
        if (@($entityRefs).Count -eq 0) {
            $path = ''
            foreach ($uri in @($evidenceUris)) {
                if ($uri -match '^file://([^#]+)') { $path = $Matches[1]; break }
                if ($uri -match '/blob/[^/]+/(.+?)(?:#L\d+)?$') { $path = $Matches[1]; break }
            }
            if (-not $path) {
                $path = (($rawId -replace '\\', '/') -replace '^\./', '').Trim()
                if ([string]::IsNullOrWhiteSpace($path)) { $path = 'main.tf' }
                if (-not $path.EndsWith('.tf')) { $path = "$path/main.tf" }
            }
            $entityRefs = @("iac:terraform:$($path.ToLowerInvariant())")
            if (-not [string]::IsNullOrWhiteSpace($resourceAddress)) {
                $entityRefs += "iac:terraform:$($path.ToLowerInvariant())#$($resourceAddress.ToLowerInvariant())"
            }
        }

        $mitre = Resolve-TerraformMitreMapping -RuleId $ruleId -Title $title -Detail $detail -Category $category

        $rawSnippets = if ($finding.PSObject.Properties['RemediationSnippets']) { $finding.RemediationSnippets } else { @() }
        $snippets = Convert-ToHashtableArray $rawSnippets
        if (@($snippets).Count -eq 0 -and -not [string]::IsNullOrWhiteSpace($remediation)) {
            $snippets = @(@{ language = 'hcl'; code = "- # existing configuration`n+ # remediation: $remediation" })
        }

        $row = New-FindingRow -Id $findingId `
            -Source 'terraform-iac' -EntityId $repositoryEntityId -EntityType 'Repository' `
            -Title $title -RuleId $ruleId -Compliant ([bool]$compliant) -ProvenanceRunId $runId `
            -Platform 'GitHub' -Category $category -Severity $severity `
            -Detail $detail -Remediation $remediation `
            -LearnMoreUrl $learnMore -ResourceId ($rawId ?? '') `
            -Frameworks $frameworks -Pillar $pillar -Impact $impact -Effort $effort `
            -DeepLinkUrl $deepLinkUrl -RemediationSnippets $snippets `
            -EvidenceUris $evidenceUris -BaselineTags $baselineTags `
            -MitreTactics $mitre.MitreTactics -MitreTechniques $mitre.MitreTechniques `
            -EntityRefs $entityRefs -ToolVersion $toolVersion
        if ($null -ne $row) {
            $normalized.Add($row)
        }
    }

    return @($normalized)
}