Private/EntryPoint.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 Entry Points

function Invoke-VCFPatchScanner {

    <#
        .SYNOPSIS
        Execute a VCF vulnerability scan.

        .DESCRIPTION
        Main orchestrator function that coordinates discovery, advisory loading,
        vulnerability scanning, and findings export. Returns structured result
        with scan metrics and output paths.

        .PARAMETER AdvisoryPath
        Path to security advisory file (default: ./securityAdvisory.json).

        .PARAMETER FindingsOutputPath
        Path where findings JSON file should be written (default: ./findings/scan-results.json).

        .PARAMETER EnvironmentType
        Environment type: vcf5, vcf9, vsphere8, vvf9.

        .PARAMETER EnvironmentConfig
        Environment configuration object (from New-PatchScanEnvironment).
        If not provided, must be loadable from settings file.

        .PARAMETER ExportCsv
        If specified, also export findings to CSV at this path.

        .PARAMETER IncludeOnlyFqdns
        Optional list of endpoint FQDNs to inventory. When non-empty, only those FQDNs
        are queried and all others are skipped. Used by the retry-failed-only scan path.

        .PARAMETER TimeoutSeconds
        Per-endpoint connection timeout in seconds passed to ConvertTo-ScanInventory (1-900, default 30).

        .PARAMETER UseLiveInventory
        When set, collects live inventory from all configured API endpoints. Omit only when
        replaying a previously collected inventory object.

        .EXAMPLE
        $envConfig = New-PatchScanEnvironment -Name "Lab" -Type vcf9 `
            -SddcManagerServer "sddc.example.com" -SddcManagerUser "administrator@vsphere.local" `
            -VcfOpsServer "ops.example.com" -VcfOpsUser "admin@local" `
            -VcfFMServer "flt-fm01.example.com" -VcfFMUser "admin@vsp.local"
        Invoke-VCFPatchScanner -AdvisoryPath "advisory.json" -EnvironmentConfig $envConfig -EnvironmentType vcf9 -UseLiveInventory

        .OUTPUTS
        [PSCustomObject] Result with: Status, ScanStartedAt, ScanCompletedAt, DurationSeconds,
        AdvisoriesLoaded, AdvisoriesFiltered, FindingsCount, FailedEndpoints, FindingsPath, ExitCode

        .NOTES
        Entry point called by Invoke-VCFPatchScanner.ps1. All discovery and inventory errors are captured in FailedEndpoints; the function returns a result object rather than throwing on partial failures.
    #>


    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    Param (
        [Parameter(Mandatory = $true)]  [ValidateNotNullOrEmpty()] [String]$AdvisoryPath,
        [Parameter(Mandatory = $true)]  [ValidateNotNull()]        [PSCustomObject]$EnvironmentConfig,
        [Parameter(Mandatory = $true)]  [ValidateSet('vcf5', 'vcf9', 'vsphere8', 'vvf9')] [String]$EnvironmentType,
        [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [String]$ExportCsv,
        [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [String]$FindingsOutputPath = "findings/scan-results.json",
        [Parameter(Mandatory = $false)] [AllowEmptyCollection()]   [String[]]$IncludeOnlyFqdns = @(),
        [Parameter(Mandatory = $false)] [ValidateRange(1, 900)]    [Int]$TimeoutSeconds = 30,
        [Parameter(Mandatory = $false)] [Switch]$UseLiveInventory,
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [ValidateNotNull()] [String]$VcenterBuildMapFile = ''
    )

    $startTime = Get-Date
    $result = [PSCustomObject]@{
        Status = "Failed"
        ScanStartedAt = $startTime
        ScanCompletedAt = $null
        DurationSeconds = 0
        AdvisoriesLoaded = 0
        AdvisoriesFiltered = 0
        FindingsCount = 0
        FailedEndpoints = @()
        FindingsPath = $FindingsOutputPath
        ExitCode = 1
    }

    try {
        Write-LogMessage -Type INFO -Message "VCF Patch Scan starting for environment type: $EnvironmentType"

        $advisories = Get-SecurityAdvisory -FilePath $AdvisoryPath -ValidateSchema
        if ($null -eq $advisories) {
            Write-LogMessage -Type ERROR -Message "Failed to load the security advisory database from '$AdvisoryPath'."
            return $result
        }

        $result.AdvisoriesLoaded = @($advisories).Count
        $filteredAdvisories = Select-AdvisoryByEnvironmentType -Advisories $advisories -EnvironmentType $EnvironmentType
        $result.AdvisoriesFiltered = @($filteredAdvisories).Count
        Write-LogMessage -Type INFO -Message "Loaded $($result.AdvisoriesLoaded) advisories from $AdvisoryPath; $($result.AdvisoriesFiltered) applicable for $EnvironmentType"

        if ($result.AdvisoriesFiltered -eq 0) {
            Write-LogMessage -Type INFO -Message "No applicable advisories found for this environment; no vulnerabilities to scan"
            $result.Status = "Success"
            $result.ExitCode = 0
            Export-PatchScanFindings -Findings @() -OutputPath $FindingsOutputPath
            if ($ExportCsv) {
                Export-PatchScanFindingsCSV -Findings @() -OutputPath $ExportCsv
            }
            $result.FindingsPath = $FindingsOutputPath
            return $result
        }

        Write-LogMessage -Type INFO -Message "Building environment inventory..."
        $inventoryResult = ConvertTo-ScanInventory -EnvironmentConfig $EnvironmentConfig `
            -EnvironmentType $EnvironmentType -IncludeOnlyFqdns $IncludeOnlyFqdns `
            -TimeoutSeconds $TimeoutSeconds -UseLiveInventory:$UseLiveInventory `
            -VcenterBuildMapFile $VcenterBuildMapFile
        $inventory = $inventoryResult.Inventory
        $result.FailedEndpoints = @($inventoryResult.FailedEndpoints)

        $findings = Invoke-VulnerabilityScan -Advisories $filteredAdvisories -Inventory $inventory
        $result.FindingsCount = @($findings).Count

        Write-LogMessage -Type INFO -Message "Found $($result.FindingsCount) vulnerabilities"

        Write-LogMessage -Type INFO -Message "Building inventory status report..."
        $inventoryStatus = ConvertTo-InventoryStatus -Inventory $inventory -Findings $findings

        Write-LogMessage -Type INFO -Message "Exporting findings to: $FindingsOutputPath"
        $exportData = @($findings) + @($inventoryStatus)
        Export-PatchScanFindings -Findings $exportData -FailedEndpoints $result.FailedEndpoints `
            -VersionCatalog $inventoryResult.FleetCatalog -VcfMinorVersion $inventoryResult.VcfMinorVersion `
            -OutputPath $FindingsOutputPath

        if ($ExportCsv) {
            Write-LogMessage -Type INFO -Message "Exporting findings to CSV: $ExportCsv"
            Export-PatchScanFindingsCSV -Findings $findings -OutputPath $ExportCsv
        }

        $result.Status = "Success"
        $result.ExitCode = 0
        $result.ScanCompletedAt = Get-Date
        $result.DurationSeconds = [Int]($result.ScanCompletedAt - $result.ScanStartedAt).TotalSeconds
        Write-LogMessage -Type INFO -Message "VCF Patch Scan completed in $($result.DurationSeconds)s: $($result.FindingsCount) vulnerabilities found across $($result.AdvisoriesFiltered) applicable advisories"
    }
    catch {
        Write-LogMessage -Type ERROR -Message "VCF Patch Scan failed: $($_.Exception.Message)"
        $result.Status = "Failed"
        $result.ExitCode = 1
    }
    finally {
        if ($null -eq $result.ScanCompletedAt) {
            $result.ScanCompletedAt = Get-Date
            $result.DurationSeconds = [Int]($result.ScanCompletedAt - $result.ScanStartedAt).TotalSeconds
        }
    }

    return $result
}

function ConvertTo-ScanInventory {

    <#
        .SYNOPSIS
        Convert environment configuration to scannable inventory format.

        .DESCRIPTION
        Transforms the environment config object into a hashtable keyed by component name
        with arrays of servers containing Version and Fqdn properties.

        Attempts to collect live inventory from APIs; falls back to mock inventory from
        configuration if live collection fails or is unavailable. Each per-endpoint API
        call is isolated in its own try/catch so a single unreachable endpoint does not
        abort the scan — the endpoint is recorded in FailedEndpoints and collection
        continues for the remaining endpoints.

        When IncludeOnlyFqdns is non-empty only the listed FQDNs are inventoried; all
        others are skipped. This is used by the retry-failed-only scan path.

        .PARAMETER EnvironmentConfig
        Environment configuration object (from New-PatchScanEnvironment).

        .PARAMETER EnvironmentType
        Environment type determining which components are present.

        .PARAMETER IncludeOnlyFqdns
        Optional allowlist of endpoint FQDNs to inventory. When provided, any endpoint
        whose FQDN is not in this list is silently skipped. Pass the FQDNs from the
        previous scan's failedEndpoints to re-scan only those that failed.

        .PARAMETER TimeoutSeconds
        Per-endpoint connection timeout in seconds (1-900, default 30).

        .PARAMETER UseLiveInventory
        When set, attempts to collect live inventory from APIs.

        .PARAMETER VcenterBuildMapFile
        Optional path to vcenterBuildMap.json written by Convert-BroadcomAdvisoriesToSchema.ps1
        alongside securityAdvisory.json. When provided, vCenter inventory entries are enriched
        with a BuildVersion property so the UI can display the MOB build number alongside the
        advisory-compatible version string (e.g. "8.0.3.25413364 (8.0.3.00900)"). The scanner
        degrades gracefully when the file is absent — advisory comparison is unaffected.

        .EXAMPLE
        $result = ConvertTo-ScanInventory -EnvironmentConfig $envConfig -EnvironmentType 'vcf9' -TimeoutSeconds 30 -UseLiveInventory
        if ($result.FailedEndpoints.Count -gt 0) {
            Write-LogMessage -Type WARNING -Message "Some endpoints could not be inventoried."
        }

        .OUTPUTS
        [PSCustomObject] Object with Inventory ([Hashtable]) and FailedEndpoints ([Object[]]).

        .NOTES
        Reads component credentials from environment variables via Get-InventoryPassword. Sets $Script:_retryFailedFqdns when IncludeOnlyFqdns is non-empty.
    #>


    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    Param (
        [Parameter(Mandatory = $true)]  [ValidateNotNull()]  [PSCustomObject]$EnvironmentConfig,
        [Parameter(Mandatory = $true)]  [ValidateSet('vcf5', 'vcf9', 'vsphere8', 'vvf9')] [String]$EnvironmentType,
        [Parameter(Mandatory = $false)] [AllowEmptyCollection()] [String[]]$IncludeOnlyFqdns = @(),
        [Parameter(Mandatory = $false)] [ValidateRange(1, 900)] [Int]$TimeoutSeconds = 30,
        [Parameter(Mandatory = $false)] [Switch]$UseLiveInventory,
        [Parameter(Mandatory = $false)] [AllowEmptyString()] [ValidateNotNull()] [String]$VcenterBuildMapFile = ''
    )

    $inventory       = @{}
    $fleetCatalog    = @()
    $vcfMinorVersion = ''
    $failedEndpoints = [System.Collections.Generic.List[Object]]::new()

    if ($UseLiveInventory) {
        Write-LogMessage -Type INFO -Message "Attempting to collect live inventory from APIs (timeout: $($TimeoutSeconds)s)"

        if (-not [String]::IsNullOrWhiteSpace($VcenterBuildMapFile)) {
            $vcenterBuildMaps = Get-VcenterBuildMap -BuildMapPath $VcenterBuildMapFile
        } else {
            $vcenterBuildMaps = @{ VersionToBuild = @{}; BuildToVersion = @{} }
        }

        if ($EnvironmentType -in 'vcf5', 'vcf9') {
            $fqdn = $EnvironmentConfig.sddcManagerServer
            if ($fqdn -and $EnvironmentConfig.sddcManagerUser -and
                ($IncludeOnlyFqdns.Count -eq 0 -or $fqdn -in $IncludeOnlyFqdns)) {
                try {
                    $sddcInventory = Get-SddcManagerInventory -Server $fqdn `
                        -User $EnvironmentConfig.sddcManagerUser -TimeoutSeconds $TimeoutSeconds `
                        -VcenterBuildMaps $vcenterBuildMaps
                    $inventory += $sddcInventory

                    # Extract the two-part VCF minor version (e.g. "5.2") from the SDDC Manager
                    # version string (e.g. "5.2.0.0-24108943") so the UI label reads
                    # "VMware Cloud Foundation 5.2" rather than the generic "VMware Cloud Foundation 5".
                    # Re-derived on every scan so an upgraded environment is reflected immediately.
                    if ($EnvironmentType -eq 'vcf5' -and $inventory.ContainsKey('SDDC Manager')) {
                        $rawSddcVer = [String]($inventory['SDDC Manager'][0].Version)
                        if ($rawSddcVer -match '^(\d+\.\d+)') {
                            $vcfMinorVersion = $Matches[1]
                        }
                    }
                }
                catch {
                    Write-LogMessage -Type WARNING -Message "SDDC Manager inventory failed for '$fqdn': $($_.Exception.Message) — skipping endpoint."
                    $failedEndpoints.Add([PSCustomObject]@{ Fqdn = $fqdn; Component = "SDDC Manager"; ErrorMessage = $_.Exception.Message })
                }
            }
        }

        if ($EnvironmentType -in 'vsphere8', 'vvf9', 'vcf5', 'vcf9') {
            $fqdn = $EnvironmentConfig.vcenterServer
            if ($fqdn -and $EnvironmentConfig.vcenterUser -and
                ($IncludeOnlyFqdns.Count -eq 0 -or $fqdn -in $IncludeOnlyFqdns)) {
                try {
                    $vcenterInventory = Get-VcenterInventory -Server $fqdn `
                        -User $EnvironmentConfig.vcenterUser -TimeoutSeconds $TimeoutSeconds `
                        -VcenterBuildMaps $vcenterBuildMaps
                    $inventory += $vcenterInventory
                }
                catch {
                    Write-LogMessage -Type WARNING -Message "vCenter inventory failed for '$fqdn': $($_.Exception.Message) — skipping endpoint."
                    $failedEndpoints.Add([PSCustomObject]@{ Fqdn = $fqdn; Component = "vCenter"; ErrorMessage = $_.Exception.Message })
                }
            }
        }

        # For standalone vSphere 8 environments, NSX is not managed by SDDC Manager
        # so its inventory must be fetched directly from the NSX Manager REST API.
        # vcf5 and vcf9 environments receive NSX inventory through Get-SddcManagerInventory.
        # vvf9 does not use NSX.
        if ($EnvironmentType -eq 'vsphere8') {
            $fqdn = $EnvironmentConfig.nsxManagerServer
            if ($fqdn -and ($IncludeOnlyFqdns.Count -eq 0 -or $fqdn -in $IncludeOnlyFqdns)) {
                try {
                    $nsxInventory = Get-StandaloneNsxManagerInventory `
                        -NsxManagerFqdn $fqdn -TimeoutSeconds $TimeoutSeconds
                    $inventory += $nsxInventory
                }
                catch {
                    Write-LogMessage -Type WARNING -Message "NSX Manager inventory failed for '$fqdn': $($_.Exception.Message) — skipping endpoint."
                    $failedEndpoints.Add([PSCustomObject]@{ Fqdn = $fqdn; Component = "NSX"; ErrorMessage = $_.Exception.Message })
                }

                # Edge nodes are best-effort: Get-NsxEdgeInventory returns @() silently
                # when NSX_MANAGER_PASSWORD is absent (vsphere8 env var path).
                $edgeNodes = Get-NsxEdgeInventory -NsxManagerFqdn $fqdn -TimeoutSeconds $TimeoutSeconds
                if ($edgeNodes.Count -gt 0) {
                    $inventory["NSX Edge"] = $edgeNodes
                    Write-LogMessage -Type INFO -Message "Collected $($edgeNodes.Count) NSX Edge node(s): $(($edgeNodes | ForEach-Object { $_.Fqdn }) -join ', ')"
                }
            }
        }

        $standaloneVcFqdns = @()

        if ($EnvironmentType -in 'vcf9', 'vvf9') {
            # Fleet Manager runs first so the API path it succeeds on can be used to determine
            # whether this is a VCF 9.0 (lcops) or 9.1+ (VSP fleet-lcm) environment, which
            # controls whether the native VCF Operations API call is necessary.
            $opsFromFleet = $null
            $fqdn = $EnvironmentConfig.vcfFMServer
            if ($fqdn -and $EnvironmentConfig.vcfFMUser -and
                ($IncludeOnlyFqdns.Count -eq 0 -or $fqdn -in $IncludeOnlyFqdns)) {
                try {
                    $fleetInventory = Get-FleetManagerInventory -Server $fqdn `
                        -User $EnvironmentConfig.vcfFMUser -TimeoutSeconds $TimeoutSeconds `
                        -AllowVspUserFallback:($EnvironmentType -eq 'vvf9')

                    $opsFromFleet = $fleetInventory['_OpsVersionFromFleet']
                    $fleetApiPath = [String]$fleetInventory['_FleetApiPath']
                    [Void]$fleetInventory.Remove('_OpsVersionFromFleet')
                    [Void]$fleetInventory.Remove('_FleetApiPath')

                    $vcfMinorVersion = switch ($fleetApiPath) {
                        'vsp'   { '9.1' }
                        'lcops' { '9.0' }
                        default { '' }
                    }

                    $inventory += $fleetInventory
                }
                catch {
                    Write-LogMessage -Type WARNING -Message "Fleet Manager inventory failed for '$fqdn': $($_.Exception.Message) — skipping endpoint."
                    $failedEndpoints.Add([PSCustomObject]@{ Fqdn = $fqdn; Component = "Fleet Lifecycle"; ErrorMessage = $_.Exception.Message })
                }
            }

            # VCF Operations inventory — conditional on detected VCF minor version:
            # 9.1 (VSP path): Fleet Controller is authoritative; skip the native VCF Operations
            # API to avoid an extra credential requirement. The entry is built from Fleet data.
            # 9.0 (lcops path) or unknown: the native API is the primary source; Fleet data
            # supplements it for build-number enrichment when available.
            $fqdn = $EnvironmentConfig.vcfOpsServer
            if ($fqdn -and $EnvironmentConfig.vcfOpsUser -and
                ($IncludeOnlyFqdns.Count -eq 0 -or $fqdn -in $IncludeOnlyFqdns)) {
                if ($vcfMinorVersion -eq '9.1' -and $EnvironmentType -eq 'vcf9') {
                    if ($null -ne $opsFromFleet) {
                        $inventory['VCF Operations'] = @([PSCustomObject]@{
                            Fqdn       = $opsFromFleet.Fqdn
                            Version    = $opsFromFleet.Version
                            DomainName = "VCF Fleet"
                        })
                        Write-LogMessage -Type INFO -Message "VCF Operations (9.1): version sourced from Fleet Controller: $($opsFromFleet.Version)"
                    }
                } else {
                    try {
                        $opsInventory = Get-VcfOpsInventory -Server $fqdn `
                            -User $EnvironmentConfig.vcfOpsUser -TimeoutSeconds $TimeoutSeconds
                        # VVF9 only: standalone vCenters are genuinely standalone (no SDDC Manager).
                        # VCF9 is excluded because VCF Operations returns vCenters that also appear
                        # in SDDC Manager workload domains; scanning them separately would produce
                        # duplicate results with no reliable filter at discovery time.
                        if ($EnvironmentType -eq 'vvf9') {
                            $standaloneVcFqdns = @($opsInventory['_StandaloneVcenterFqdns'])
                            if ($standaloneVcFqdns.Count -gt 0) {
                                Write-LogMessage -Type INFO -Message "VVF9: scanning $($standaloneVcFqdns.Count) standalone vCenter(s): $(($standaloneVcFqdns | Sort-Object) -join ', ')"
                            }
                        }
                        [Void]$opsInventory.Remove('_StandaloneVcenterFqdns')
                        $inventory += $opsInventory
                    }
                    catch {
                        Write-LogMessage -Type WARNING -Message "VCF Operations inventory failed for '$fqdn': $($_.Exception.Message) — skipping endpoint."
                        $failedEndpoints.Add([PSCustomObject]@{ Fqdn = $fqdn; Component = "VCF Operations"; ErrorMessage = $_.Exception.Message })
                    }

                    if ($null -ne $opsFromFleet -and $inventory.ContainsKey('VCF Operations')) {
                        $opsEntries = [System.Collections.Generic.List[PSCustomObject]]::new()
                        foreach ($entry in @($inventory['VCF Operations'])) {
                            if ($entry.Fqdn -ieq $opsFromFleet.Fqdn) {
                                $opsEntries.Add([PSCustomObject]@{
                                    Fqdn       = $entry.Fqdn
                                    Version    = $opsFromFleet.Version
                                    DomainName = $entry.DomainName
                                })
                            } else {
                                $opsEntries.Add($entry)
                            }
                        }
                        $inventory['VCF Operations'] = $opsEntries.ToArray()
                        Write-LogMessage -Type DEBUG -Message "VCF Operations version enriched from Fleet: $($opsFromFleet.Version)"
                    }
                }
            }

            # VVF9 9.1: standalone vCenters are stored in the environment config from wizard
            # authentication — use them directly instead of querying VCF Operations at scan time
            # (the native Ops API is skipped on 9.1; Fleet Controller is authoritative).
            # The Python server serialises the list as JSON in the VCENTER_FQDNS env var.
            # VCF9 is excluded: see comment at the vcf9/vvf9 9.0 path above.
            if ($vcfMinorVersion -eq '9.1' -and $EnvironmentType -eq 'vvf9') {
                $vcenterFqdnsJson = [System.Environment]::GetEnvironmentVariable('VCENTER_FQDNS')
                if (-not [String]::IsNullOrWhiteSpace($vcenterFqdnsJson)) {
                    try {
                        $configVcFqdns = @(ConvertFrom-Json $vcenterFqdnsJson |
                            Where-Object { -not [String]::IsNullOrWhiteSpace([String]$_) })
                        if ($configVcFqdns.Count -gt 0) {
                            $standaloneVcFqdns = $configVcFqdns
                            Write-LogMessage -Type INFO -Message "$EnvironmentType 9.1: using $($standaloneVcFqdns.Count) stored standalone vCenter FQDN(s): $(($standaloneVcFqdns | Sort-Object) -join ', ')"
                        } else {
                            Write-LogMessage -Type WARNING -Message "$EnvironmentType 9.1: VCENTER_FQDNS parsed but contained no non-empty entries — standalone vCenter inventory skipped."
                        }
                    }
                    catch {
                        Write-LogMessage -Type WARNING -Message "$EnvironmentType 9.1: failed to parse VCENTER_FQDNS — standalone vCenter inventory skipped: $($_.Exception.Message)"
                    }
                } else {
                    Write-LogMessage -Type WARNING -Message "$EnvironmentType 9.1: VCENTER_FQDNS not set — standalone vCenter inventory skipped. Re-authenticate in the environment editor to populate the vCenter list."
                }
            }
        }

        foreach ($vcFqdn in $standaloneVcFqdns) {
            if ([String]::IsNullOrWhiteSpace($EnvironmentConfig.vcenterUser)) { continue }
            if ($IncludeOnlyFqdns.Count -gt 0 -and $vcFqdn -notin $IncludeOnlyFqdns) { continue }
            try {
                $vcInventory = Get-VcenterInventory -Server $vcFqdn `
                    -User $EnvironmentConfig.vcenterUser -TimeoutSeconds $TimeoutSeconds `
                    -VcenterBuildMaps $vcenterBuildMaps
                $inventory += $vcInventory
            }
            catch {
                Write-LogMessage -Type WARNING -Message "Standalone vCenter inventory failed for '$vcFqdn': $($_.Exception.Message) — skipping endpoint."
                $failedEndpoints.Add([PSCustomObject]@{ Fqdn = $vcFqdn; Component = "vCenter"; ErrorMessage = $_.Exception.Message })
            }
        }

        # vRSLCM is optional for VCF 5.x environments.
        if ($EnvironmentType -eq 'vcf5') {
            $vrslcmPass = [System.Environment]::GetEnvironmentVariable("VRSLCM_PASSWORD")
            $fqdn       = $EnvironmentConfig.vrslcmServer
            $vrslcmUser = if (-not [String]::IsNullOrWhiteSpace($EnvironmentConfig.vrslcmUser)) {
                $EnvironmentConfig.vrslcmUser
            } else {
                "admin@local"
            }

            # Auto-discover vRSLCM from SDDC Manager when not explicitly configured.
            # Only attempted when VRSLCM_PASSWORD is set — without it the inventory
            # step below would fail and there is nothing useful to discover.
            if ([String]::IsNullOrWhiteSpace($fqdn) -and -not [String]::IsNullOrWhiteSpace($vrslcmPass)) {
                $sddcServer = $EnvironmentConfig.sddcManagerServer
                $sddcUser   = $EnvironmentConfig.sddcManagerUser
                if (-not [String]::IsNullOrWhiteSpace($sddcServer) -and -not [String]::IsNullOrWhiteSpace($sddcUser)) {
                    Write-LogMessage -Type INFO -Message "vRSLCM not configured — auto-discovering from SDDC Manager: $sddcServer..."
                    $discovery = Get-VrslcmFromSddcManager -Server $sddcServer -User $sddcUser -TimeoutSeconds $TimeoutSeconds
                    if (-not [String]::IsNullOrWhiteSpace($discovery.VrslcmFqdn)) {
                        $fqdn = $discovery.VrslcmFqdn
                        Write-LogMessage -Type INFO -Message "vRSLCM auto-discovered: $fqdn"
                    } elseif ($null -eq $discovery.Error) {
                        Write-LogMessage -Type INFO -Message "No vRSLCM registered with SDDC Manager $sddcServer — skipping vRSLCM inventory."
                    } else {
                        Write-LogMessage -Type DEBUG -Message "vRSLCM auto-discovery failed: $($discovery.Error)"
                    }
                }
            }

            if (-not [String]::IsNullOrWhiteSpace($fqdn) -and -not [String]::IsNullOrWhiteSpace($vrslcmPass) -and
                ($IncludeOnlyFqdns.Count -eq 0 -or $fqdn -in $IncludeOnlyFqdns)) {
                try {
                    $vrslcmInventory = Get-VrslcmInventory -Server $fqdn `
                        -User $vrslcmUser -Password $vrslcmPass -TimeoutSeconds $TimeoutSeconds
                    $inventory += $vrslcmInventory
                }
                catch {
                    Write-LogMessage -Type WARNING -Message "vRSLCM inventory failed for '$fqdn': $($_.Exception.Message) — skipping endpoint."
                    $failedEndpoints.Add([PSCustomObject]@{ Fqdn = $fqdn; Component = "vRSLCM"; ErrorMessage = $_.Exception.Message })
                }
            }
        }

        if ($inventory.Count -gt 0) {
        # Fleet-tier components (Fleet Lifecycle, VCF Operations) already carry
        # DomainName = "VCF Fleet" from their collection functions. SDDC Manager-managed
            # components (vCenter, NSX, ESXi, SDDC Manager) carry their workload domain name
            # from Invoke-VcfGetDomains. For standalone environments (vsphere8, vvf9) the
            # VCF Domain concept does not apply — DomainName is left empty.
            foreach ($componentType in @($inventory.Keys)) {
                $inventory[$componentType] = @($inventory[$componentType] | ForEach-Object {
                    $nameToSet = if (-not [String]::IsNullOrWhiteSpace($_.DomainName)) {
                        [String]$_.DomainName
                    } else {
                        ''
                    }
                    Add-Member -InputObject $_ -NotePropertyName DomainName -NotePropertyValue $nameToSet -Force -PassThru
                })
            }
            if ($EnvironmentType -eq 'vcf9') {
                $cfgInstanceName = if (-not [String]::IsNullOrWhiteSpace($EnvironmentConfig.sddcManagerInstanceName)) {
                    [String]$EnvironmentConfig.sddcManagerInstanceName
                } else { '' }
                foreach ($componentType in @($inventory.Keys)) {
                    $inventory[$componentType] = @($inventory[$componentType] | ForEach-Object {
                        Add-Member -InputObject $_ -NotePropertyName InstanceName -NotePropertyValue $cfgInstanceName -Force -PassThru
                    })
                }
            }
            $failedCount = $failedEndpoints.Count
            Write-LogMessage -Type INFO -Message "Live inventory collected: $($inventory.Count) component types, $failedCount endpoint(s) failed."
            return [PSCustomObject]@{ Inventory = $inventory; FailedEndpoints = $failedEndpoints.ToArray(); FleetCatalog = $fleetCatalog; VcfMinorVersion = $vcfMinorVersion }
        }
        else {
            Write-LogMessage -Type INFO -Message "Live inventory collection returned no data; falling back to mock inventory from configuration"
        }
    }

    Write-LogMessage -Type INFO -Message "Using mock inventory from environment configuration"

    switch ($EnvironmentType) {
        'vcf5' {
            if ($EnvironmentConfig.sddcManagerServer) {
                $inventory['SDDC Manager'] = @(
                    [PSCustomObject]@{ Fqdn = $EnvironmentConfig.sddcManagerServer; Version = "Unknown"; DomainName = "" }
                )
            }
            if ($EnvironmentConfig.vcenterServer) {
                $inventory['vCenter'] = @(
                    [PSCustomObject]@{ Fqdn = $EnvironmentConfig.vcenterServer; Version = "Unknown"; DomainName = "" }
                )
            }
            # vRSLCM version only discoverable via live API; do not add placeholder when offline.
            if ($EnvironmentConfig.vrslcmServer) {
                $inventory['Fleet Lifecycle'] = @(
                    [PSCustomObject]@{ Fqdn = $EnvironmentConfig.vrslcmServer; Version = "Unknown"; DomainName = "vRSLCM" }
                )
            }
        }
        'vcf9' {
            $mockInstanceName = if (-not [String]::IsNullOrWhiteSpace($EnvironmentConfig.sddcManagerInstanceName)) {
                [String]$EnvironmentConfig.sddcManagerInstanceName
            } else { '' }
            if ($EnvironmentConfig.sddcManagerServer) {
                # DomainName is empty — the management domain name is only discoverable via
                # the live SDDC Manager API (Invoke-VcfGetDomains) and is not available here.
                $inventory['SDDC Manager'] = @(
                    [PSCustomObject]@{ Fqdn = $EnvironmentConfig.sddcManagerServer; Version = "Unknown"; DomainName = ""; InstanceName = $mockInstanceName }
                )
                # vCenter and NSX FQDNs are only discoverable via live SDDC Manager inventory.
                # Do NOT add placeholder FQDNs here — fake endpoints produce misleading findings.
            }
            if ($EnvironmentConfig.vcfOpsServer) {
                $inventory['VCF Operations'] = @(
                    [PSCustomObject]@{ Fqdn = $EnvironmentConfig.vcfOpsServer; Version = "Unknown"; DomainName = "VCF Fleet"; InstanceName = $mockInstanceName }
                )
            }
            if ($EnvironmentConfig.vcfFMServer) {
                $inventory['Fleet Lifecycle'] = @(
                    [PSCustomObject]@{ Fqdn = $EnvironmentConfig.vcfFMServer; Version = "Unknown"; DomainName = "VCF Fleet"; InstanceName = $mockInstanceName }
                )
            }
        }
        'vsphere8' {
            if ($EnvironmentConfig.vcenterServer) {
                $inventory['vCenter'] = @(
                    [PSCustomObject]@{ Fqdn = $EnvironmentConfig.vcenterServer; Version = "Unknown"; DomainName = "" }
                )
            }
            if ($EnvironmentConfig.nsxManagerServer) {
                $inventory['NSX'] = @(
                    [PSCustomObject]@{ Fqdn = $EnvironmentConfig.nsxManagerServer; Version = "Unknown"; DomainName = "" }
                )
            }
        }
        'vvf9' {
            if ($EnvironmentConfig.vcfOpsServer) {
                $inventory['VCF Operations'] = @(
                    [PSCustomObject]@{ Fqdn = $EnvironmentConfig.vcfOpsServer; Version = "Unknown"; DomainName = "VCF Fleet"; InstanceName = $mockInstanceName }
                )
            }
            if ($EnvironmentConfig.vcfFMServer) {
                $inventory['Fleet Lifecycle'] = @(
                    [PSCustomObject]@{ Fqdn = $EnvironmentConfig.vcfFMServer; Version = "Unknown"; DomainName = "VCF Fleet"; InstanceName = $mockInstanceName }
                )
            }
            # vCenter FQDNs are auto-discovered from VCF Operations at scan time and cannot be mocked.
            # vvf9 does not use NSX.
        }
    }

    return [PSCustomObject]@{ Inventory = $inventory; FailedEndpoints = @(); FleetCatalog = $fleetCatalog; VcfMinorVersion = $vcfMinorVersion }
}

function ConvertTo-InventoryStatus {

    <#
        .SYNOPSIS
        Convert scanned inventory to inventory status report.

        .DESCRIPTION
        Creates a consolidated status report for all scanned endpoints. One row per unique
        component+FQDN pair. Shows components with no vulnerabilities as "Safe" status. Each
        item's DomainName is read from the inventory object itself (set during collection:
        "VCF Fleet", workload domain name, or "N/A").

        Multiple Fleet components (e.g. Fleet Lifecycle, Salt Master, Salt RaaS) can share
        the same FQDN. Deduplication is keyed on component+FQDN rather than FQDN alone so
        all co-located Fleet components appear separately in the report.

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

        .PARAMETER Inventory
        Hashtable of scanned inventory keyed by component name.

        .EXAMPLE
        $statusReport = ConvertTo-InventoryStatus -Findings $scanFindings -Inventory $inventory
        $criticalHosts = $statusReport | Where-Object { $_.Status -eq 'Vulnerable' }

        .OUTPUTS
        [PSCustomObject[]] Array of endpoint status items (one per component+FQDN pair)

        .NOTES
        Pure transformation function. Groups inventory items with their associated findings to produce the per-host status report.
    #>


    [CmdletBinding()]
    [OutputType([Object[]])]
    Param (
        [Parameter(Mandatory = $true)] [AllowEmptyCollection()] [ValidateNotNull()] [Object[]]$Findings,
        [Parameter(Mandatory = $true)] [ValidateNotNull()] [Hashtable]$Inventory
    )

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

    # Build a set of "component|fqdn" pairs that already have vulnerability findings so we
    # do not emit a duplicate "Not Vulnerable" row alongside a real finding for the same pair.
    $endpointsWithFindings = [System.Collections.Generic.HashSet[String]]::new([System.StringComparer]::OrdinalIgnoreCase)
    foreach ($f in @($Findings)) {
        [Void]$endpointsWithFindings.Add("$($f.component)|$($f.serverFqdn)")
    }

    # Deduplicate by component+fqdn so co-located Fleet services (Salt Master, Salt RaaS, etc.
    # sharing the same host FQDN) each get their own inventory status row.
    $processedKeys = [System.Collections.Generic.HashSet[String]]::new(
        [System.StringComparer]::OrdinalIgnoreCase
    )

    foreach ($componentName in $Inventory.Keys) {
        $inventoryItems = @($Inventory[$componentName])

        foreach ($item in $inventoryItems) {
            $fqdn = $item.Fqdn
            $dedupKey = "$componentName|$fqdn"

            if (-not $processedKeys.Add($dedupKey)) {
                continue
            }

            # Only generate a "Not Vulnerable" row when no finding already covers this pair.
            if ($endpointsWithFindings.Contains($dedupKey)) {
                continue
            }

            $statusEntry = [PSCustomObject]@{
                    component      = $componentName
                    domainName     = if (-not [String]::IsNullOrWhiteSpace($item.DomainName)) { [String]$item.DomainName } else { '' }
                    clusterName    = if (-not [String]::IsNullOrWhiteSpace($item.ClusterName)) { [String]$item.ClusterName } else { '' }
                    instanceName   = if (-not [String]::IsNullOrWhiteSpace($item.InstanceName)) { [String]$item.InstanceName } else { '' }
                    serverFqdn     = $fqdn
                    currentVersion = $item.Version
                    Status         = "Safe"
                    severity       = "None"
                    vmsaId         = $null
                    cves           = $null
                    FindingsCount  = 0
                }
            if (-not [String]::IsNullOrWhiteSpace($item.BuildVersion)) {
                $statusEntry | Add-Member -NotePropertyName 'currentBuild' -NotePropertyValue ([String]$item.BuildVersion)
            }
            [Void]$endpointStatus.Add($statusEntry)
        }
    }

    , @($endpointStatus)
}

#endregion