modules/normalizers/Normalize-Trivy.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Normalizer for Aqua Security Trivy findings.
.DESCRIPTION
    Converts raw Trivy wrapper output to v3 FindingRow objects.
    Platform=GitHub, EntityType=Repository.
#>

[CmdletBinding()]
param ()

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

function ConvertTo-HashtableArray {
    param([object[]] $Items)
    $result = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($item in @($Items)) {
        if ($null -eq $item) { continue }
        if ($item -is [hashtable]) {
            $result.Add($item) | Out-Null
            continue
        }
        $table = @{}
        foreach ($property in $item.PSObject.Properties) {
            $table[$property.Name] = $property.Value
        }
        if ($table.Count -gt 0) { $result.Add($table) | Out-Null }
    }
    return @($result)
}

function Normalize-Trivy {
    [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
        }

        $canonicalId = ''
        if ($rawId) {
            if ($rawId -match '(?i)^(sha256:[a-f0-9]{64})$') {
                $canonicalId = "trivy/image/$($Matches[1].ToLowerInvariant())"
            } elseif ($rawId -match '(?i)(sha256:[a-f0-9]{64})') {
                $canonicalId = "trivy/image/$($Matches[1].ToLowerInvariant())"
            } else {
                try {
                    $canonicalId = ConvertTo-CanonicalRepoId -RepoId $rawId
                } catch {
                    $canonicalId = "trivy/$($rawId.ToLowerInvariant() -replace '\\', '/' -replace '^\./?', '')"
                    if ($canonicalId -eq 'trivy/') { $canonicalId = 'trivy/local' }
                }
            }
        }

        $findingId = if ($finding.PSObject.Properties['Id'] -and $finding.Id) {
            [string]$finding.Id
        } else {
            [guid]::NewGuid().ToString()
        }
        if (-not $canonicalId) {
            $canonicalId = "trivy/$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 { 'Supply Chain' }
        $ruleId = if ($finding.PSObject.Properties['RuleId'] -and $finding.RuleId) { [string]$finding.RuleId } else { '' }

        $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 { $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 { '' }
        $pillar = if ($finding.PSObject.Properties['Pillar'] -and $finding.Pillar) { [string]$finding.Pillar } else { 'Security' }
        $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 { '' }
        $deepLinkUrl = if ($finding.PSObject.Properties['DeepLinkUrl'] -and $finding.DeepLinkUrl) { [string]$finding.DeepLinkUrl } else { '' }
        $frameworks = if ($finding.PSObject.Properties['Frameworks'] -and $finding.Frameworks) { ConvertTo-HashtableArray -Items @($finding.Frameworks) } else { @() }
        $evidenceUris = if ($finding.PSObject.Properties['EvidenceUris'] -and $finding.EvidenceUris) { @([string[]]$finding.EvidenceUris) } else { @() }
        $baselineTags = if ($finding.PSObject.Properties['BaselineTags'] -and $finding.BaselineTags) { @([string[]]$finding.BaselineTags) } else { @() }
        $entityRefs = if ($finding.PSObject.Properties['EntityRefs'] -and $finding.EntityRefs) { @([string[]]$finding.EntityRefs) } else { @() }
        $toolVersion = if ($finding.PSObject.Properties['ToolVersion'] -and $finding.ToolVersion) { [string]$finding.ToolVersion } else { '' }
        $remediationSnippets = if ($finding.PSObject.Properties['RemediationSnippets'] -and $finding.RemediationSnippets) { ConvertTo-HashtableArray -Items @($finding.RemediationSnippets) } else { @() }
        $scoreDelta = $null
        if ($finding.PSObject.Properties['ScoreDelta'] -and $null -ne $finding.ScoreDelta) {
            try { $scoreDelta = [double]$finding.ScoreDelta } catch { $scoreDelta = $null }
        }

        $row = New-FindingRow -Id $findingId `
            -Source 'trivy' -EntityId $canonicalId -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 ?? '') `
            -Pillar $pillar -Impact $impact -Effort $effort -DeepLinkUrl $deepLinkUrl `
            -Frameworks @($frameworks) -EvidenceUris @($evidenceUris) `
            -BaselineTags @($baselineTags) -RemediationSnippets @($remediationSnippets) `
            -ScoreDelta $scoreDelta -EntityRefs @($entityRefs) -ToolVersion $toolVersion
        # Skip null rows (validation failed)
        if ($null -ne $row) {
            $normalized.Add($row)
        }
    }

    return @($normalized)
}