Private/Findings.ps1

# Copyright (c) 2026 Broadcom. All Rights Reserved.
# Broadcom Confidential. The term "Broadcom" refers to Broadcom Inc.
# and/or its subsidiaries.
#
# =============================================================================
#
# SOFTWARE LICENSE AGREEMENT
#
# Copyright (c) CA, Inc. All rights reserved.
#
# You are hereby granted a non-exclusive, worldwide, royalty-free license
# under CA, Inc.'s copyrights to use, copy, modify, and distribute this
# software in source code or binary form for use in connection with CA, Inc.
# products.
#
# This copyright notice shall be included in all copies or substantial
# portions of the software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.
#
# =============================================================================

#region Findings Export

function Export-PatchScanFindings {

    <#
        .SYNOPSIS
        Export vulnerability findings to JSON file.

        .DESCRIPTION
        Writes findings and optional metadata to a JSON wrapper object:
        { "findings": [...], "failedEndpoints": [...], "versionCatalog": [...], "vcfMinorVersion": "..." }.
        The Python server reads all keys; failedEndpoints and versionCatalog are empty arrays when
        not applicable; vcfMinorVersion is an empty string when the environment is not VCF 9.x or
        the version could not be detected. Creates the output directory if it does not exist.

        .PARAMETER FailedEndpoints
        Optional array of endpoints that could not be inventoried during this scan.
        Each entry should have Fqdn, Component, and ErrorMessage properties.

        .PARAMETER Findings
        Array of vulnerability findings (from Invoke-VulnerabilityScan).

        .PARAMETER OutputPath
        Full path to output JSON file. Must be an absolute path.

        .PARAMETER VcfMinorVersion
        Optional VCF minor version string detected during inventory collection (e.g. "9.0" or
        "9.1"). Empty when the environment is not VCF 9.x or the version could not be detected.

        .PARAMETER VersionCatalog
        Optional array from Get-FleetManagerReleaseVersions mapping VCF release versions
        to per-component build numbers. Included in the output so consumers can correlate
        Fleet-reported build numbers with advisory-compatible release version strings.

        .EXAMPLE
        $findings = Invoke-VulnerabilityScan -Advisories $advisories -Inventory $inventory
        Export-PatchScanFindings -Findings $findings -OutputPath "C:\findings\scan-results.json"

        .EXAMPLE
        Export-PatchScanFindings -Findings $findings -FailedEndpoints $failedEndpoints `
            -VersionCatalog $catalog -VcfMinorVersion "9.1" -OutputPath "C:\findings\scan-results.json"

        .OUTPUTS
        None. Creates file at OutputPath.

        .NOTES
        Writes findings JSON atomically via a temp file in the same directory followed by a rename.
        The temp file is removed on failure. Creates the output directory when absent.
    #>


    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $false)] [AllowEmptyCollection()] [Object[]]$FailedEndpoints = @(),
        [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [Object[]]$Findings,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$OutputPath,
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [ValidateNotNull()] [String]$VcfMinorVersion = '',
        [Parameter(Mandatory = $false)] [AllowEmptyCollection()] [Object[]]$VersionCatalog = @()
    )

    # OutputPath must be absolute to avoid resolution ambiguity; the Python server provides the full path.
    if (-not [System.IO.Path]::IsPathRooted($OutputPath)) {
        throw [System.InvalidOperationException]::new("FindingsOutputPath must be an absolute path, got: $OutputPath")
    }
    $resolvedPath = $OutputPath
    $tempPath     = $null

    try {
        $directory = [System.IO.Path]::GetDirectoryName($resolvedPath)
        if (-not (Test-Path -LiteralPath $directory -PathType Container)) {
            New-Item -ItemType Directory -Path $directory -Force | Out-Null
            Write-LogMessage -Type DEBUG -Message "Created findings directory: $directory"
        }

        $output = [PSCustomObject]@{
            findings        = @($Findings)
            failedEndpoints = @($FailedEndpoints)
            vcfMinorVersion = $VcfMinorVersion
            versionCatalog  = @($VersionCatalog)
        }
        $json     = ConvertTo-Json -InputObject $output -Depth $Script:JSON_SERIALIZE_DEPTH -ErrorAction Stop
        $tempPath = Join-Path -Path $directory -ChildPath "findings_$(New-Guid).tmp"

        # Atomic write: temp file in same directory + rename so readers never see a partial file.
        $utf8NoBom = [System.Text.UTF8Encoding]::new($false)
        [System.IO.File]::WriteAllText($tempPath, $json, $utf8NoBom)
        Move-Item -LiteralPath $tempPath -Destination $resolvedPath -Force
        $tempPath = $null

        $versionSuffix = if (-not [String]::IsNullOrWhiteSpace($VcfMinorVersion)) { ", VCF $VcfMinorVersion detected" } else { "" }
        Write-LogMessage -Type INFO -Message "Findings exported to JSON: $resolvedPath ($(@($Findings).Count) findings, $(@($FailedEndpoints).Count) failed endpoints, $(@($VersionCatalog).Count) version catalog entries$versionSuffix)"
    }
    catch {
        if ($null -ne $tempPath -and (Test-Path -LiteralPath $tempPath -PathType Leaf)) {
            Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
        }
        Write-LogMessage -Type ERROR -Message "Failed to export findings: $($_.Exception.Message)"
        throw
    }
}

function Export-PatchScanFindingsCSV {

    <#
        .SYNOPSIS
        Export vulnerability findings to CSV file.

        .DESCRIPTION
        Writes findings as CSV with one row per vulnerability instance.
        Useful for import into spreadsheet or reporting tools.

        .PARAMETER Findings
        Array of vulnerability findings (from Invoke-VulnerabilityScan).

        .PARAMETER OutputPath
        Full path to output CSV file (absolute or relative to module root).

        .EXAMPLE
        $findings = Invoke-VulnerabilityScan -Advisories $advisories -Inventory $inventory
        Export-PatchScanFindingsCSV -Findings $findings -OutputPath "findings/scan-results.csv"

        .OUTPUTS
        None. Creates file at OutputPath.

        .NOTES
        Writes findings CSV atomically via a temp file in the same directory followed by a rename.
        The temp file is removed on failure. Creates the output directory when absent.
    #>


    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [Object[]]$Findings,
        [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$OutputPath
    )

    # FindingsOutputPath must be absolute path to avoid resolution ambiguity.
    # The caller (Python server) is responsible for providing the full path.
    if (-not [System.IO.Path]::IsPathRooted($OutputPath)) {
        throw [System.InvalidOperationException]::new("FindingsOutputPath must be an absolute path, got: $OutputPath")
    }
    $resolvedPath = $OutputPath
    $tempPath     = $null

    try {
        $directory = [System.IO.Path]::GetDirectoryName($resolvedPath)
        if (-not (Test-Path -LiteralPath $directory -PathType Container)) {
            New-Item -ItemType Directory -Path $directory -Force | Out-Null
        }

        $findings = @($Findings)
        $csvData  = $findings | Select-Object -Property `
            @{Name = 'Component'; Expression = { $_.component }},
            @{Name = 'CurrentVersion'; Expression = { $_.currentVersion }},
            @{Name = 'VulnerableMinimumVersion'; Expression = { $_.vulnerableMinimumVersion }},
            @{Name = 'FixedVersions'; Expression = { ($_.fixedVersions -join '; ') }},
            @{Name = 'Severity'; Expression = { $_.severity }},
            @{Name = 'CVEs'; Expression = { ($_.cves -join '; ') }},
            @{Name = 'VMSA_ID'; Expression = { $_.vmsaId }},
            @{Name = 'ServerFqdn'; Expression = { $_.serverFqdn }}

        $tempPath = Join-Path -Path $directory -ChildPath "findings_$(New-Guid).tmp"

        # Atomic write: export to temp file then rename so readers never see a partial file.
        $csvData | Export-Csv -Path $tempPath -NoTypeInformation -Encoding UTF8 -ErrorAction Stop
        Move-Item -LiteralPath $tempPath -Destination $resolvedPath -Force
        $tempPath = $null

        Write-LogMessage -Type INFO -Message "Findings exported to CSV: $resolvedPath ($($findings.Count) findings)"
    }
    catch {
        if ($null -ne $tempPath -and (Test-Path -LiteralPath $tempPath -PathType Leaf)) {
            Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue
        }
        Write-LogMessage -Type ERROR -Message "Failed to export CSV: $($_.Exception.Message)"
        throw
    }
}

#endregion