modules/Invoke-Trivy.ps1

#Requires -Version 7.0
<#
.SYNOPSIS
    Wrapper for Aqua Security Trivy CLI.
.DESCRIPTION
    Runs trivy filesystem or repo scan for dependency vulnerabilities and returns
    supply chain security findings as PSObjects. If trivy is not installed, writes
    a warning and returns an empty result.
    Never throws -- designed for graceful degradation in the orchestrator.
 
    Security: Verify trivy binary integrity. Download from official GitHub
    releases only: https://github.com/aquasecurity/trivy/releases
 
    JSON output is written to a temp file (--output) to avoid stderr/stdout
    mixing. The temp file is cleaned up in a finally block.
.PARAMETER RepoPath
    Path to scan for vulnerabilities. Defaults to current directory.
    Legacy alias: -ScanPath.
.PARAMETER ScanType
    Type of scan to perform: 'fs' (filesystem) or 'repo' (remote repository).
    Defaults to 'fs'.
#>

[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
param (
    [Alias('ScanPath')]
    [string] $RepoPath = '.',

    [ValidateSet('fs', 'repo')]
    [string] $ScanType = 'fs',

    [string] $RemoteUrl
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# Dot-source shared modules for Remove-Credentials, Invoke-RemoteRepoClone
$sharedDir = Join-Path (Split-Path $PSScriptRoot -Parent) 'modules' 'shared'
if (-not $sharedDir -or -not (Test-Path $sharedDir)) {
    $sharedDir = Join-Path $PSScriptRoot 'shared'
}
$sanitizePath = Join-Path $sharedDir 'Sanitize.ps1'
if (Test-Path $sanitizePath) { . $sanitizePath }
$missingToolPath = Join-Path $sharedDir 'MissingTool.ps1'
if (Test-Path $missingToolPath) { . $missingToolPath }
$remoteClonePath = Join-Path $sharedDir 'RemoteClone.ps1'
if (Test-Path $remoteClonePath) { . $remoteClonePath }
# Bootstrap Invoke-WithTimeout for CLI timeout protection
$cliTimeoutPath = Join-Path $sharedDir 'CliTimeout.ps1'
if (Test-Path $cliTimeoutPath) { . $cliTimeoutPath }


$envelopePath = Join-Path $sharedDir 'New-WrapperEnvelope.ps1'
if (Test-Path $envelopePath) { . $envelopePath }
if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) { function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } } }
if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) {
    function Remove-Credentials { param ([string]$Text) return $Text }
}

# Minimum trivy version known to produce reliable JSON output
$script:MinTrivyVersion = [version]'0.50.0'

function Test-TrivyInstalled {
    $null -ne (Get-Command trivy -ErrorAction SilentlyContinue)
}

function Get-TrivyVersionInfo {
    $raw = ''
    $parsed = $null
    try {
        $versionOutput = & trivy --version 2>$null
        if ($versionOutput) {
            $raw = ($versionOutput | Out-String).Trim()
            # trivy --version outputs lines like "Version: 0.56.2"
            if ($raw -match '(\d+\.\d+\.\d+)') {
                $parsed = [version]$Matches[1]
            }
        }
    } catch {
        # Ignore version parse failures — proceed with warning
    }
    return [PSCustomObject]@{
        ParsedVersion = $parsed
        RawOutput     = $raw
    }
}

function Get-TextArray {
    param([object] $Value)
    if ($null -eq $Value) { return @() }
    if ($Value -is [string]) {
        if ([string]::IsNullOrWhiteSpace($Value)) { return @() }
        return @($Value.Trim())
    }
    if ($Value -is [System.Collections.IEnumerable]) {
        return @($Value | ForEach-Object {
                if ($null -eq $_) { return }
                $candidate = [string]$_
                if (-not [string]::IsNullOrWhiteSpace($candidate)) { $candidate.Trim() }
            } | Where-Object { $_ } | Select-Object -Unique)
    }
    return @([string]$Value)
}

function Get-TrivyScoreDelta {
    param([object] $Vuln)
    $cvss = if ($Vuln.PSObject.Properties['CVSS']) { $Vuln.CVSS } else { $null }
    if ($null -eq $cvss) { return $null }
    foreach ($entry in $cvss.PSObject.Properties) {
        if ($entry.Value -and $entry.Value.PSObject.Properties['V3Score'] -and $null -ne $entry.Value.V3Score) {
            try { return [double]$entry.Value.V3Score } catch { }
        }
        if ($entry.Value -and $entry.Value.PSObject.Properties['Score'] -and $null -ne $entry.Value.Score) {
            try { return [double]$entry.Value.Score } catch { }
        }
    }
    return $null
}

function Get-TrivyLanguageForTarget {
    param([string] $Target)
    if ([string]::IsNullOrWhiteSpace($Target)) { return 'json' }
    $leaf = [System.IO.Path]::GetFileName($Target).ToLowerInvariant()
    if ($leaf -eq 'dockerfile' -or $leaf -like 'dockerfile.*') { return 'dockerfile' }
    if ($leaf -like '*.yaml' -or $leaf -like '*.yml') { return 'yaml' }
    return 'json'
}

function Get-TrivyImpact {
    param([string] $Severity)
    switch (($Severity ?? '').ToUpperInvariant()) {
        'CRITICAL' { return 'High' }
        'HIGH' { return 'High' }
        'MEDIUM' { return 'Medium' }
        'LOW' { return 'Low' }
        default { return 'Low' }
    }
}

function Get-TrivyEntityRefs {
    param(
        [object] $Result,
        [string] $RemoteUrl
    )
    $refs = [System.Collections.Generic.List[string]]::new()
    if (-not [string]::IsNullOrWhiteSpace($RemoteUrl)) { $refs.Add($RemoteUrl) | Out-Null }
    $target = if ($Result.PSObject.Properties['Target']) { [string]$Result.Target } else { '' }
    if (-not [string]::IsNullOrWhiteSpace($target)) { $refs.Add($target) | Out-Null }
    $artifactName = if ($Result.PSObject.Properties['ArtifactName']) { [string]$Result.ArtifactName } else { '' }
    if (-not [string]::IsNullOrWhiteSpace($artifactName)) { $refs.Add($artifactName) | Out-Null }

    if ($target -match '(sha256:[A-Fa-f0-9]{64})') { $refs.Add($Matches[1].ToLowerInvariant()) | Out-Null }
    if ($artifactName -match '(sha256:[A-Fa-f0-9]{64})') { $refs.Add($Matches[1].ToLowerInvariant()) | Out-Null }

    return @($refs | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
}

function Get-TrivyDeepLinkUrl {
    param(
        [string] $CheckId,
        [string] $Class
    )
    if ([string]::IsNullOrWhiteSpace($CheckId)) {
        return if ($Class -eq 'misconfig') {
            'https://aquasecurity.github.io/trivy/latest/docs/scanner/misconfiguration/'
        } else {
            'https://aquasecurity.github.io/trivy/latest/docs/scanner/vulnerability/'
        }
    }
    $id = $CheckId.ToLowerInvariant()
    if ($Class -eq 'misconfig') {
        return "https://aquasecurity.github.io/trivy/latest/docs/scanner/misconfiguration/#$id"
    }
    return "https://aquasecurity.github.io/trivy/latest/docs/scanner/vulnerability/#$id"
}

function Get-TrivyFrameworks {
    param(
        [string] $VulnerabilityId,
        [string[]] $CweIds,
        [string] $IacCheckId
    )
    $frameworks = [System.Collections.Generic.List[hashtable]]::new()
    if ($VulnerabilityId -match '^CVE-') {
        $frameworks.Add(@{ kind = 'CVE'; controlId = $VulnerabilityId.ToUpperInvariant() }) | Out-Null
    } elseif ($VulnerabilityId -match '^GHSA-') {
        $frameworks.Add(@{ kind = 'GHSA'; controlId = $VulnerabilityId.ToUpperInvariant() }) | Out-Null
    }
    foreach ($cwe in @($CweIds)) {
        if ([string]::IsNullOrWhiteSpace($cwe)) { continue }
        $frameworks.Add(@{ kind = 'CWE'; controlId = $cwe.ToUpperInvariant() }) | Out-Null
    }
    if (-not [string]::IsNullOrWhiteSpace($IacCheckId)) {
        $frameworks.Add(@{ kind = 'TrivyIaC'; controlId = $IacCheckId }) | Out-Null
    }
    return @($frameworks | Sort-Object { "$($_.kind)|$($_.controlId)" } -Unique)
}

function Get-TrivyBaselineTags {
    param(
        [string] $Target,
        [string[]] $Texts
    )
    $tags = [System.Collections.Generic.List[string]]::new()
    $isDockerfile = -not [string]::IsNullOrWhiteSpace($Target) -and ([System.IO.Path]::GetFileName($Target).ToLowerInvariant() -like 'dockerfile*')
    if (-not $isDockerfile) { return @() }

    foreach ($text in @($Texts)) {
        if ([string]::IsNullOrWhiteSpace($text)) { continue }
        foreach ($m in [regex]::Matches($text, '(CIS-DI-[0-9.]+)', 'IgnoreCase')) {
            $tags.Add($m.Groups[1].Value.ToUpperInvariant()) | Out-Null
        }
    }
    return @($tags | Select-Object -Unique)
}

function Get-TrivyEvidenceUris {
    param(
        [string] $VulnerabilityId,
        [string] $PrimaryUrl,
        [string[]] $References
    )
    $uris = [System.Collections.Generic.List[string]]::new()
    foreach ($candidate in @($PrimaryUrl) + @($References)) {
        if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
        if ($candidate -match '^https://') { $uris.Add($candidate.Trim()) | Out-Null }
    }

    if ($VulnerabilityId -match '^CVE-(\d{4})-(\d+)$') {
        $uris.Add("https://nvd.nist.gov/vuln/detail/$($VulnerabilityId.ToUpperInvariant())") | Out-Null
    } elseif ($VulnerabilityId -match '^GHSA-') {
        $uris.Add("https://github.com/advisories/$($VulnerabilityId.ToUpperInvariant())") | Out-Null
    }

    return @($uris | Select-Object -Unique)
}

if (-not (Test-TrivyInstalled)) {
    $missingMessage = "trivy is not installed. Skipping Trivy scan. Install from https://github.com/aquasecurity/trivy/releases or: brew install trivy / choco install trivy"
    Write-MissingToolNotice -Tool 'trivy' -Message $missingMessage
    return [PSCustomObject]@{
        Source   = 'trivy'
        SchemaVersion = '1.0'
        Status   = 'Skipped'
        Message  = 'trivy CLI not installed. Download from https://github.com/aquasecurity/trivy/releases'
        Findings = @()
        Errors   = @()
        Diagnostics = @(
            [PSCustomObject]@{
                Code    = 'MissingTool'
                Tool    = 'trivy'
                Message = $missingMessage
            }
        )
    }
}

# Version safety check — warn (but proceed) if below minimum known-safe version
$trivyVersionInfo = Get-TrivyVersionInfo
$trivyVersion = $trivyVersionInfo.ParsedVersion
$trivyToolVersion = if ($trivyVersionInfo.RawOutput) { $trivyVersionInfo.RawOutput } elseif ($trivyVersion) { "Version: $trivyVersion" } else { '' }
if ($null -ne $trivyVersion -and $trivyVersion -lt $script:MinTrivyVersion) {
    Write-Verbose "trivy version $trivyVersion is below the recommended minimum ($script:MinTrivyVersion). Update from https://github.com/aquasecurity/trivy/releases"
}
if ($null -eq $trivyVersion) {
    Write-Verbose "Could not determine trivy version. Verify binary integrity — download from https://github.com/aquasecurity/trivy/releases"
}

$cloneInfo = $null
$cleanupClone = $null
try {
    if ($RemoteUrl) {
        if (-not (Get-Command Invoke-RemoteRepoClone -ErrorAction SilentlyContinue)) {
            Write-Warning "RemoteClone helper not loaded; cannot scan remote URL."
            return [PSCustomObject]@{
                Source = 'trivy'
                SchemaVersion = '1.0'; Status = 'Failed'
                Message = 'RemoteClone helper unavailable'; Findings = @()
                Errors   = @()
            }
        }
        $cloneInfo = Invoke-RemoteRepoClone -RepoUrl $RemoteUrl
        if (-not $cloneInfo) {
            return [PSCustomObject]@{
                Source = 'trivy'
                SchemaVersion = '1.0'; Status = 'Failed'
                Message = "Remote clone failed or host not on allow-list: $RemoteUrl"
                Findings = @()
                Errors   = @()
            }
        }
        $cleanupClone = $cloneInfo.Cleanup
        $RepoPath = $cloneInfo.Path
        $ScanType = 'fs'
    }

    Write-Verbose "Running trivy $ScanType scan on '$RepoPath'"

    # Write JSON to a temp file to keep stderr separate from the JSON stream
    $reportFile = Join-Path ([System.IO.Path]::GetTempPath()) "trivy-report-$([guid]::NewGuid().ToString('N')).json"

    try {
        $trivyArgs = @($ScanType, '--format', 'json', '--scanners', 'vuln,misconfig', '--output', $reportFile, $RepoPath)
        $trivyExec = Invoke-WithTimeout -Command 'trivy' -Arguments $trivyArgs -TimeoutSec 300
        if ($trivyExec.Output) { Write-Verbose "trivy output: $($trivyExec.Output)" }

        $exitCode = [int]$trivyExec.ExitCode

        if ($exitCode -eq -1) {
            Write-Warning "trivy timed out after 300 seconds"
            return [PSCustomObject]@{
                Source   = 'trivy'
                SchemaVersion = '1.0'
                Status   = 'Failed'
                Message  = 'trivy timed out after 300 seconds'
                Findings = @()
                Errors   = @()
            }
        }

        # Non-zero exit with no report = hard failure
        if ($exitCode -ne 0 -and -not (Test-Path $reportFile)) {
            Write-Warning (Remove-Credentials "trivy exited with code $exitCode and produced no report")
            return [PSCustomObject]@{
                Source   = 'trivy'
                SchemaVersion = '1.0'
                Status   = 'Failed'
                Message  = (Remove-Credentials "trivy exited with code $exitCode and produced no report")
                Findings = @()
                Errors   = @()
            }
        }

        $json = $null
        if (Test-Path $reportFile) {
            $jsonText = Get-Content $reportFile -Raw -ErrorAction SilentlyContinue
            if ($jsonText) {
                try {
                    $json = $jsonText | ConvertFrom-Json -ErrorAction Stop
                } catch {
                    Write-Warning "trivy report JSON parse failed: $(Remove-Credentials -Text ([string]$_))"
                    return [PSCustomObject]@{
                        Source   = 'trivy'
                        SchemaVersion = '1.0'
                        Status   = 'Failed'
                        Message  = Remove-Credentials -Text "Report JSON parse failed: $([string]$_)"
                        Findings = @()
                        Errors   = @()
                    }
                }
            }
        }
    } finally {
        Remove-Item $reportFile -Force -ErrorAction SilentlyContinue
    }

    $findings = [System.Collections.Generic.List[PSCustomObject]]::new()

    $results = $null
    if ($null -ne $json -and $json.PSObject.Properties['Results'] -and $json.Results) {
        $results = $json.Results
    }

    if ($results) {
        foreach ($result in $results) {
            $target = ''
            if ($result.PSObject.Properties['Target'] -and $result.Target) {
                $target = $result.Target
            }

            $vulns = $null
            if ($result.PSObject.Properties['Vulnerabilities'] -and $result.Vulnerabilities) {
                $vulns = $result.Vulnerabilities
            }
            foreach ($vuln in @($vulns)) {
                if ($null -eq $vuln) { continue }
                $cveId = ''
                if ($vuln.PSObject.Properties['VulnerabilityID'] -and $vuln.VulnerabilityID) {
                    $cveId = $vuln.VulnerabilityID
                }

                $pkgName = ''
                if ($vuln.PSObject.Properties['PkgName'] -and $vuln.PkgName) {
                    $pkgName = $vuln.PkgName
                }

                $title = if ($cveId -and $pkgName) { "$cveId ($pkgName)" }
                         elseif ($cveId) { $cveId }
                         elseif ($pkgName) { $pkgName }
                         else { 'Unknown vulnerability' }

                # Map trivy severity (CRITICAL/HIGH/MEDIUM/LOW/UNKNOWN) to schema severity
                $rawSev = ''
                if ($vuln.PSObject.Properties['Severity'] -and $vuln.Severity) {
                    $rawSev = $vuln.Severity
                }
                $severity = switch ($rawSev.ToUpperInvariant()) {
                    'CRITICAL' { 'Critical' }
                    'HIGH'     { 'High' }
                    'MEDIUM'   { 'Medium' }
                    'LOW'      { 'Low' }
                    default    { 'Info' }
                }

                $installedVer = ''
                if ($vuln.PSObject.Properties['InstalledVersion'] -and $vuln.InstalledVersion) {
                    $installedVer = $vuln.InstalledVersion
                }
                $fixedVer = ''
                if ($vuln.PSObject.Properties['FixedVersion'] -and $vuln.FixedVersion) {
                    $fixedVer = $vuln.FixedVersion
                }

                $description = ''
                if ($vuln.PSObject.Properties['Description'] -and $vuln.Description) {
                    $description = $vuln.Description
                }
                $vulnTitle = ''
                if ($vuln.PSObject.Properties['Title'] -and $vuln.Title) {
                    $vulnTitle = $vuln.Title
                }

                # Build detail string
                $detailParts = [System.Collections.Generic.List[string]]::new()
                if ($vulnTitle) { $detailParts.Add($vulnTitle) }
                if ($target) { $detailParts.Add("File: $target") }
                if ($installedVer) { $detailParts.Add("Installed: $installedVer") }
                if ($fixedVer) { $detailParts.Add("Fixed: $fixedVer") }
                if ($description -and -not $vulnTitle) {
                    # Truncate long descriptions
                    $desc = if ($description.Length -gt 200) { $description.Substring(0, 200) + '...' } else { $description }
                    $detailParts.Add($desc)
                }
                $detail = $detailParts -join '. '

                # Build remediation
                $remediation = ''
                if ($fixedVer -and $pkgName) {
                    $remediation = "Upgrade $pkgName to $fixedVer or later."
                } elseif ($fixedVer) {
                    $remediation = "Upgrade to version $fixedVer or later."
                }

                $learnMoreUrl = ''
                if ($vuln.PSObject.Properties['PrimaryURL'] -and $vuln.PrimaryURL) {
                    $learnMoreUrl = $vuln.PrimaryURL
                }
                $references = if ($vuln.PSObject.Properties['References']) { Get-TextArray -Value $vuln.References } else { @() }
                $cweIds = if ($vuln.PSObject.Properties['CweIDs']) { Get-TextArray -Value $vuln.CweIDs } elseif ($vuln.PSObject.Properties['CWEIDs']) { Get-TextArray -Value $vuln.CWEIDs } else { @() }
                $frameworks = Get-TrivyFrameworks -VulnerabilityId $cveId -CweIds $cweIds -IacCheckId ''
                $evidenceUris = Get-TrivyEvidenceUris -VulnerabilityId $cveId -PrimaryUrl $learnMoreUrl -References $references
                $baselineTags = Get-TrivyBaselineTags -Target $target -Texts @($detail, $description, $vulnTitle)
                $entityRefs = Get-TrivyEntityRefs -Result $result -RemoteUrl $RemoteUrl
                $deepLinkUrl = Get-TrivyDeepLinkUrl -CheckId $cveId -Class 'vuln'
                $scoreDelta = Get-TrivyScoreDelta -Vuln $vuln
                $effort = if (-not [string]::IsNullOrWhiteSpace($fixedVer)) { 'Low' } else { 'Medium' }
                $impact = Get-TrivyImpact -Severity $rawSev
                $remediationSnippets = @()
                if (-not [string]::IsNullOrWhiteSpace($fixedVer) -and -not [string]::IsNullOrWhiteSpace($pkgName)) {
                    $remediationSnippets = @(
                        @{
                            language = Get-TrivyLanguageForTarget -Target $target
                            before   = if (-not [string]::IsNullOrWhiteSpace($installedVer)) { "${pkgName}:${installedVer}" } else { $pkgName }
                            after    = "${pkgName}:${fixedVer}"
                        }
                    )
                }
                $resourceId = if (-not [string]::IsNullOrWhiteSpace($target)) { $target } else { $RepoPath }
                if ($resourceId -match '(sha256:[A-Fa-f0-9]{64})') { $resourceId = $Matches[1].ToLowerInvariant() }

                $findings.Add([PSCustomObject]@{
                    Id           = [guid]::NewGuid().ToString()
                    Category     = 'Supply Chain'
                    Title        = $title
                    RuleId       = $cveId
                    Severity     = $severity
                    Compliant    = $false
                    Detail       = $detail
                    Remediation  = $remediation
                    ResourceId   = $resourceId
                    LearnMoreUrl = $learnMoreUrl
                    Pillar       = 'Security'
                    Impact       = $impact
                    Effort       = $effort
                    DeepLinkUrl  = $deepLinkUrl
                    RemediationSnippets = @($remediationSnippets)
                    EvidenceUris = @($evidenceUris)
                    BaselineTags = @($baselineTags)
                    ScoreDelta   = $scoreDelta
                    Frameworks   = @($frameworks)
                    EntityRefs   = @($entityRefs)
                    ToolVersion  = $trivyToolVersion
                })
            }

            $misconfigs = $null
            if ($result.PSObject.Properties['Misconfigurations'] -and $result.Misconfigurations) {
                $misconfigs = $result.Misconfigurations
            }
            foreach ($misconfig in @($misconfigs)) {
                if ($null -eq $misconfig) { continue }
                $checkId = if ($misconfig.PSObject.Properties['ID'] -and $misconfig.ID) { [string]$misconfig.ID } else { '' }
                $title = if ($misconfig.PSObject.Properties['Title'] -and $misconfig.Title) { [string]$misconfig.Title } elseif ($checkId) { $checkId } else { 'Unknown misconfiguration' }
                $rawSev = if ($misconfig.PSObject.Properties['Severity'] -and $misconfig.Severity) { [string]$misconfig.Severity } else { 'MEDIUM' }
                $severity = switch ($rawSev.ToUpperInvariant()) {
                    'CRITICAL' { 'Critical' }
                    'HIGH'     { 'High' }
                    'MEDIUM'   { 'Medium' }
                    'LOW'      { 'Low' }
                    default    { 'Info' }
                }
                $description = if ($misconfig.PSObject.Properties['Description'] -and $misconfig.Description) { [string]$misconfig.Description } else { '' }
                $resolution = if ($misconfig.PSObject.Properties['Resolution'] -and $misconfig.Resolution) { [string]$misconfig.Resolution } else { '' }
                $primaryUrl = if ($misconfig.PSObject.Properties['PrimaryURL'] -and $misconfig.PrimaryURL) { [string]$misconfig.PrimaryURL } else { '' }
                $references = if ($misconfig.PSObject.Properties['References']) { Get-TextArray -Value $misconfig.References } else { @() }
                $frameworks = Get-TrivyFrameworks -VulnerabilityId '' -CweIds @() -IacCheckId $checkId
                $evidenceUris = Get-TrivyEvidenceUris -VulnerabilityId '' -PrimaryUrl $primaryUrl -References $references
                $baselineTags = Get-TrivyBaselineTags -Target $target -Texts @($title, $description, $resolution)
                $entityRefs = Get-TrivyEntityRefs -Result $result -RemoteUrl $RemoteUrl
                $resourceId = if (-not [string]::IsNullOrWhiteSpace($target)) { $target } else { $RepoPath }
                if ($resourceId -match '(sha256:[A-Fa-f0-9]{64})') { $resourceId = $Matches[1].ToLowerInvariant() }
                $detail = @(
                    if (-not [string]::IsNullOrWhiteSpace($description)) { $description }
                    if (-not [string]::IsNullOrWhiteSpace($target)) { "File: $target" }
                ) -join '. '

                $findings.Add([PSCustomObject]@{
                    Id           = [guid]::NewGuid().ToString()
                    Category     = 'Supply Chain'
                    Title        = $title
                    RuleId       = $checkId
                    Severity     = $severity
                    Compliant    = $false
                    Detail       = $detail
                    Remediation  = $resolution
                    ResourceId   = $resourceId
                    LearnMoreUrl = $primaryUrl
                    Pillar       = 'Security'
                    Impact       = Get-TrivyImpact -Severity $rawSev
                    Effort       = if (-not [string]::IsNullOrWhiteSpace($resolution)) { 'Low' } else { 'Medium' }
                    DeepLinkUrl  = Get-TrivyDeepLinkUrl -CheckId $checkId -Class 'misconfig'
                    RemediationSnippets = @()
                    EvidenceUris = @($evidenceUris)
                    BaselineTags = @($baselineTags)
                    ScoreDelta   = $null
                    Frameworks   = @($frameworks)
                    EntityRefs   = @($entityRefs)
                    ToolVersion  = $trivyToolVersion
                })
            }
        }
    }

    if ($null -eq $findings) { $findings = @() }

    return [PSCustomObject]@{
        Source   = 'trivy'
        SchemaVersion = '1.0'
        Status   = 'Success'
        Message  = ''
        Findings = @($findings)
        Errors   = @()
    }
} catch {
    Write-Warning "Trivy scan failed: $(Remove-Credentials -Text ([string]$_))"
    return [PSCustomObject]@{
        Source   = 'trivy'
        SchemaVersion = '1.0'
        Status   = 'Failed'
        Message  = Remove-Credentials -Text ([string]$_)
        Findings = @()
        Errors   = @()
    }
}finally {
    if ($cleanupClone) {
        try { & $cleanupClone } catch { Write-Verbose "trivy clone cleanup failed: $(Remove-Credentials -Text ([string]$_.Exception.Message))" }
    }
}