Private/Advisory.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 Advisory Loading and Parsing $Script:ADVISORY_SCHEMA_VERSION = "2.0" # Product family → component membership map used by Select-AdvisoryByProductFamily. # VCF is a superset of VVF, which is a superset of vSphere. # 'ESX' is listed alongside 'ESXi' because some advisories use the shorter form; both refer # to the same hypervisor. The scraper normalises to 'ESXi' where possible. # 'VCF Operations Workload Mobility' is the product-line name for HCX. # Both the current Broadcom UI names and the older "VCF XXX" advisory names are listed so # advisories authored in either naming era are correctly classified. $Script:PRODUCT_FAMILY_COMPONENTS = @{ VCF = @('ESXi', 'ESX', 'vCenter', 'NSX', 'SDDC Manager', 'VCF Operations', 'VCF Operations for Logs', 'VCF Operations for Networks', 'VCF Operations Workload Mobility', 'VCF Automation', 'VCF Services Runtime', 'Fleet Lifecycle', 'VCF Fleet Management', 'Identity Broker', 'VCF Identity', 'VCF Identity Broker', 'Salt Master', 'VCF Salt Master', 'Salt RaaS', 'VCF Salt RaaS', 'Software Depot', 'VCF Software Depot', 'SDDC Lifecycle', 'VCF SDDC Lifecycle', 'Telemetry', 'VCF Telemetry') VVF = @('ESXi', 'ESX', 'vCenter', 'VCF Operations', 'VCF Operations for Logs') vSphere = @('ESXi', 'ESX', 'vCenter') } function Invoke-AdvisoryDownloadIfChanged { <# .SYNOPSIS Download the upstream advisory database only when it has changed, using ETag caching. .DESCRIPTION Issues a lightweight HEAD request to retrieve the upstream ETag. If the ETag matches the value stored in the sidecar cache file (<DestinationPath>.etag), the download is skipped entirely. Otherwise, the full file is fetched via GET, validated for schema compatibility (major version must be 2.x), and written atomically using a temp-file rename. The new ETag is persisted to the sidecar on success. The sidecar ETag file lives beside the destination file with a ".etag" extension, e.g. "securityAdvisory.json.etag". It contains only the raw ETag string, no quotes. .PARAMETER DestinationPath Absolute path where the advisory JSON file should be written. Must already exist (use the module's built-in Data/securityAdvisory.json) or the directory must be writable. .PARAMETER TimeoutSeconds Network timeout in seconds applied to both the HEAD check and the full file download. Default: 10. Increase to 30 or more on slow connections to avoid premature download failures. .PARAMETER Uri URI of the upstream advisory JSON file. .EXAMPLE $result = Invoke-AdvisoryDownloadIfChanged -DestinationPath "C:\VcfPatchScanner\Data\securityAdvisory.json" if ($result.Downloaded) { Write-Host "Advisory database updated to $($result.UpdatedAt)" } .NOTES Returns [PSCustomObject] with: Downloaded ([Bool]) — true when the file was replaced. Skipped ([Bool]) — true when ETags matched; file unchanged. UpstreamEtag ([String]) — ETag returned by the server. UpdatedAt ([String]) — updatedAt from the newly-written file, or from the existing file when skipped. ErrorMessage ([String]) — non-empty on failure. #> [CmdletBinding()] [OutputType([PSCustomObject])] Param ( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$DestinationPath, [Parameter(Mandatory = $false)] [ValidateRange(1, 300)] [Int]$TimeoutSeconds = 10, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [String]$Uri = "https://raw.githubusercontent.com/vmware/powershell-module-for-vcf-patch-scanner/main/data/securityAdvisory.json" ) $etagPath = "$DestinationPath.etag" $localEtag = "" if (Test-Path -LiteralPath $etagPath -PathType Leaf) { $localEtag = (Get-Content -LiteralPath $etagPath -Raw).Trim() } # HEAD request — only download if ETag changed. $upstreamEtag = "" try { $headResp = Invoke-WebRequest -Uri $Uri -Method Head -TimeoutSec $TimeoutSeconds -ErrorAction Stop $upstreamEtag = ($headResp.Headers["ETag"] ?? "").Trim('"') } catch { return [PSCustomObject]@{ Downloaded = $false Skipped = $false UpstreamEtag = "" UpdatedAt = "" ErrorMessage = "HEAD request failed: $($_.Exception.Message)" } } if ($localEtag -and $upstreamEtag -and $localEtag -eq $upstreamEtag) { $existingUpdatedAt = "" if (Test-Path -LiteralPath $DestinationPath -PathType Leaf) { try { $doc = Get-Content -LiteralPath $DestinationPath -Raw | ConvertFrom-Json -Depth 3 $existingUpdatedAt = if ($doc.updatedAt) { [String]$doc.updatedAt } elseif ($doc.generatedAt) { [String]$doc.generatedAt } else { "" } } catch { } } return [PSCustomObject]@{ Downloaded = $false Skipped = $true UpstreamEtag = $upstreamEtag UpdatedAt = $existingUpdatedAt ErrorMessage = "" } } # GET the full file. $tempPath = "$DestinationPath.$(New-Guid).tmp" try { $getResp = Invoke-WebRequest -Uri $Uri -Method Get -TimeoutSec $TimeoutSeconds -ErrorAction Stop $body = $getResp.Content $getEtag = ($getResp.Headers["ETag"] ?? "").Trim('"') if (-not $getEtag) { $getEtag = $upstreamEtag } } catch { return [PSCustomObject]@{ Downloaded = $false Skipped = $false UpstreamEtag = $upstreamEtag UpdatedAt = "" ErrorMessage = "Download failed: $($_.Exception.Message)" } } # Validate schema before touching the file on disk. try { $document = $body | ConvertFrom-Json -Depth 5 -ErrorAction Stop } catch { return [PSCustomObject]@{ Downloaded = $false Skipped = $false UpstreamEtag = $upstreamEtag UpdatedAt = "" ErrorMessage = "Upstream file is not valid JSON: $($_.Exception.Message)" } } $schemaVersion = if ($document.schemaVersion) { [String]$document.schemaVersion } elseif ($document.SchemaVersion) { [String]$document.SchemaVersion } else { "" } if (-not $schemaVersion.StartsWith("2.")) { return [PSCustomObject]@{ Downloaded = $false Skipped = $false UpstreamEtag = $upstreamEtag UpdatedAt = "" ErrorMessage = "Upstream schema version '$schemaVersion' is incompatible (expected 2.x)." } } $advisories = if ($document.advisories) { @($document.advisories) } elseif ($document.Advisories) { @($document.Advisories) } else { @() } if ($advisories.Count -eq 0) { return [PSCustomObject]@{ Downloaded = $false Skipped = $false UpstreamEtag = $upstreamEtag UpdatedAt = "" ErrorMessage = "Upstream file contains no advisories." } } $updatedAt = if ($document.updatedAt) { [String]$document.updatedAt } elseif ($document.generatedAt) { [String]$document.generatedAt } else { "" } # Atomic write: temp file → rename. try { $destDir = Split-Path -Path $DestinationPath -Parent if (-not (Test-Path -Path $destDir -PathType Container)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } $utf8NoBom = [System.Text.UTF8Encoding]::new($false) [System.IO.File]::WriteAllText($tempPath, $body, $utf8NoBom) Move-Item -LiteralPath $tempPath -Destination $DestinationPath -Force } catch { if (Test-Path -LiteralPath $tempPath -PathType Leaf) { Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue } return [PSCustomObject]@{ Downloaded = $false Skipped = $false UpstreamEtag = $upstreamEtag UpdatedAt = "" ErrorMessage = "Could not write advisory file: $($_.Exception.Message)" } } # Persist the new ETag. try { [System.IO.File]::WriteAllText($etagPath, $getEtag, [System.Text.Encoding]::UTF8) } catch { } return [PSCustomObject]@{ Downloaded = $true Skipped = $false UpstreamEtag = $getEtag UpdatedAt = $updatedAt ErrorMessage = "" } } function Get-SecurityAdvisory { <# .SYNOPSIS Load security advisory documents from a local file or upstream URI. .DESCRIPTION When FilePath is supplied, loads and optionally validates the local advisory JSON. When Uri is supplied, uses ETag-based caching to download the file only if it has changed since the last fetch, then loads from the updated local copy. DestinationPath is required with Uri to specify where to store the downloaded file. .PARAMETER DestinationPath Required when Uri is specified. Absolute path to the local advisory file that receives the downloaded content and stores the sidecar ETag cache. .PARAMETER FilePath Path to a local advisory JSON file. Must be an absolute path. .PARAMETER TimeoutSeconds Network timeout in seconds used when Uri is specified. Default: 10. .PARAMETER Uri URI of the upstream advisory JSON. When supplied, an ETag-aware HEAD+GET sequence is used so the full file is only downloaded when its content has changed. .PARAMETER ValidateSchema When specified, enforces schema version compatibility after loading. .EXAMPLE $advisories = Get-SecurityAdvisory -FilePath "C:\VcfPatchScanner\Data\securityAdvisory.json" .EXAMPLE $advisories = Get-SecurityAdvisory ` -Uri "https://raw.githubusercontent.com/vmware/powershell-module-for-vcf-patch-scanner/main/data/securityAdvisory.json" ` -DestinationPath "C:\VcfPatchScanner\Data\securityAdvisory.json" .NOTES Throws [System.ArgumentException] when neither FilePath nor Uri is supplied, or when Uri is supplied without DestinationPath. Throws [System.IO.FileNotFoundException] when FilePath does not exist on disk. Throws [System.InvalidOperationException] on schema version mismatch, JSON parse failure, or download error. #> [CmdletBinding()] [OutputType([Object[]])] Param ( [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [String]$DestinationPath, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [String]$FilePath, [Parameter(Mandatory = $false)] [ValidateRange(1, 300)] [Int]$TimeoutSeconds = 10, [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [String]$Uri, [Parameter(Mandatory = $false)] [Switch]$ValidateSchema ) if ([String]::IsNullOrWhiteSpace($FilePath) -and [String]::IsNullOrWhiteSpace($Uri)) { throw [System.ArgumentException]::new("Either FilePath or Uri must be provided.") } if (-not [String]::IsNullOrWhiteSpace($Uri)) { if ([String]::IsNullOrWhiteSpace($DestinationPath)) { throw [System.ArgumentException]::new("DestinationPath is required when Uri is specified.") } if (-not [System.IO.Path]::IsPathRooted($DestinationPath)) { throw [System.InvalidOperationException]::new("DestinationPath must be absolute, got: $DestinationPath") } $result = Invoke-AdvisoryDownloadIfChanged -Uri $Uri -DestinationPath $DestinationPath -TimeoutSeconds $TimeoutSeconds if ($result.ErrorMessage) { throw [System.InvalidOperationException]::new("Advisory download failed: $($result.ErrorMessage)") } if ($result.Downloaded) { Write-LogMessage -Type INFO -Message "Advisory database updated (updatedAt: $($result.UpdatedAt))." } else { Write-LogMessage -Type DEBUG -Message "Advisory database is current — ETag matched, no download needed." } # Load from the (possibly just-updated) local file. $FilePath = $DestinationPath } if ($FilePath -match '[/\\]\.\.[/\\]' -or $FilePath -match '[/\\]\.\.$') { throw [System.InvalidOperationException]::new("Advisory file path contains invalid traversal sequences: $FilePath") } if (-not [System.IO.Path]::IsPathRooted($FilePath)) { throw [System.InvalidOperationException]::new("Advisory file path must be absolute, got: $FilePath") } if (-not (Test-Path -LiteralPath $FilePath -PathType Leaf)) { throw [System.IO.FileNotFoundException]::new("Advisory file not found: $FilePath") } $fileInfo = Get-Item -LiteralPath $FilePath $maxSizeBytes = 50MB if ($fileInfo.Length -gt $maxSizeBytes) { throw [System.InvalidOperationException]::new("Advisory file exceeds maximum size of $($maxSizeBytes / 1MB) MB: $($fileInfo.Length / 1MB) MB") } try { $content = Get-Content -LiteralPath $FilePath -Raw -ErrorAction Stop $document = ConvertFrom-Json -InputObject $content -Depth $Script:JSON_PARSE_MAX_DEPTH -ErrorAction Stop if ($ValidateSchema) { Test-AdvisorySchemaValidity -AdvisoryDocument $document } if ($null -ne $document.advisories -and $document.advisories -is [System.Collections.IEnumerable]) { return @($document.advisories) } if ($null -ne $document.Advisories -and $document.Advisories -is [System.Collections.IEnumerable]) { return @($document.Advisories) } if ($document -is [System.Collections.IEnumerable] -and $document -isnot [String]) { return @($document) } return @($document) } catch { throw [System.InvalidOperationException]::new("Failed to parse advisory file: $($_.Exception.Message)", $_.Exception) } } function ConvertFrom-AdvisoryDocument { <# .SYNOPSIS Parse and validate an advisory document structure. .DESCRIPTION Validates that the supplied advisory object contains the required fields (vmsaId and severity). Logs a WARNING when impactedComponents is absent. Returns the advisory unchanged when it passes validation; throws on missing mandatory fields. .PARAMETER Advisory Advisory document to parse (PSCustomObject from ConvertFrom-Json). .EXAMPLE $validated = ConvertFrom-AdvisoryDocument -Advisory $rawAdvisory .OUTPUTS [PSCustomObject] The validated advisory object, returned as-is. .NOTES Throws [System.InvalidOperationException] when vmsaId or severity is absent. Used internally by Select-AdvisoryByEnvironmentType, Select-AdvisoryByProductFamily, Select-AdvisoryByComponent, and Get-AdvisoryComponentMatches. #> [CmdletBinding()] [OutputType([PSCustomObject])] Param ( [Parameter(Mandatory = $true)] [ValidateNotNull()] [PSCustomObject]$Advisory ) if ([String]::IsNullOrWhiteSpace($Advisory.vmsaId)) { throw [System.InvalidOperationException]::new("Advisory missing vmsaId") } if ([String]::IsNullOrWhiteSpace($Advisory.severity)) { throw [System.InvalidOperationException]::new("Advisory $($Advisory.vmsaId) missing severity") } if ($null -eq $Advisory.impactedComponents -or $Advisory.impactedComponents.Count -eq 0) { Write-LogMessage -Type WARNING -Message "Advisory $($Advisory.vmsaId) has no impactedComponents" } return $Advisory } function Get-AdvisoryComponentMatches { <# .SYNOPSIS Get advisories that match a specific component name. .DESCRIPTION Iterates the advisory array and returns flattened advisory-component pairs whose component name exactly matches ComponentName (case-sensitive). Each result object carries vmsaId, severity, component metadata (minimumVersions, fixedVersions, kbArticles, cves), and fixedVersionUrl. .PARAMETER Advisories Array of advisory documents as returned by Get-SecurityAdvisory. .PARAMETER ComponentName Canonical component name to match (e.g. "ESXi", "NSX", "vCenter"). Matching is case-sensitive to align with the Component Registry. .EXAMPLE $advisories = Get-SecurityAdvisory -FilePath $advisoryFilePath $esxiMatches = Get-AdvisoryComponentMatches -Advisories $advisories -ComponentName 'ESXi' .OUTPUTS [PSCustomObject[]] Matched advisory-component pairs with fields: vmsaId, severity, componentName, minimumVersions, fixedVersions, kbArticles, cves, fixedVersionUrl. .NOTES Invalid advisories (missing vmsaId or severity) are logged at WARNING level and skipped. Returns an empty array when no advisories match the component name. #> [CmdletBinding()] [OutputType([Object[]])] Param ( [Parameter(Mandatory = $true)] [ValidateNotNull()] [Object[]]$Advisories, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String]$ComponentName ) $advisoryMatches = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($advisory in $Advisories) { try { $advisory = ConvertFrom-AdvisoryDocument -Advisory $advisory } catch { Write-LogMessage -Type WARNING -Message "Skipping invalid advisory: $($_.Exception.Message)" continue } foreach ($component in @($advisory.impactedComponents)) { $componentNameFromAdvisory = [String]$component.component if ($componentNameFromAdvisory -eq $ComponentName) { $advisoryMatches.Add([PSCustomObject]@{ vmsaId = $advisory.vmsaId severity = $advisory.severity componentName = $componentNameFromAdvisory minimumVersions = $component.minimumVersions fixedVersions = $component.fixedVersions kbArticles = $component.kbArticles cves = $component.cves fixedVersionUrl = $component.fixedVersionUrl }) } } } return @($advisoryMatches) } function Test-AdvisorySchemaValidity { <# .SYNOPSIS Validate advisory document schema version and structure. .DESCRIPTION Ensures the advisory document meets the expected schema version and contains required fields. Schema version 2.0 requires: schemaVersion "2.x", advisories array. Each advisory requires: vmsaId, severity, impactedComponents. Each component requires: component, minimumVersions, and either fixedVersions or kbArticles. .PARAMETER AdvisoryDocument Advisory document (root object or wrapper). .EXAMPLE Test-AdvisorySchemaValidity -AdvisoryDocument $document .OUTPUTS [Void] Throws on schema mismatch or missing required fields. .NOTES Throws [System.InvalidOperationException] on schema major-version mismatch, missing vmsaId or severity, or missing minimumVersions on any component entry. Logs a WARNING (does not throw) when impactedComponents is absent on an advisory. #> [CmdletBinding()] Param ( [Parameter(Mandatory = $true)] [ValidateNotNull()] [PSCustomObject]$AdvisoryDocument ) $schemaVersion = if ($null -ne $AdvisoryDocument.schemaVersion) { [String]$AdvisoryDocument.schemaVersion } elseif ($null -ne $AdvisoryDocument.SchemaVersion) { [String]$AdvisoryDocument.SchemaVersion } else { "1.0" } $schemaMajor = [Int]($schemaVersion.Split('.')[0]) $expectedMajor = [Int]($Script:ADVISORY_SCHEMA_VERSION.Split('.')[0]) if ($schemaMajor -ne $expectedMajor) { throw [System.InvalidOperationException]::new( "Advisory database is schema v$schemaVersion; this scanner release requires v$($Script:ADVISORY_SCHEMA_VERSION). " + "Run Convert-BroadcomAdvisoriesToSchema.ps1 to regenerate the advisory database.") } $advisories = if ($null -ne $AdvisoryDocument.advisories) { @($AdvisoryDocument.advisories) } elseif ($null -ne $AdvisoryDocument.Advisories) { @($AdvisoryDocument.Advisories) } else { @($AdvisoryDocument) } foreach ($advisory in $advisories) { if ([String]::IsNullOrWhiteSpace($advisory.vmsaId)) { throw [System.InvalidOperationException]::new("Advisory missing required field: vmsaId") } if ([String]::IsNullOrWhiteSpace($advisory.severity)) { throw [System.InvalidOperationException]::new("Advisory $($advisory.vmsaId) missing required field: severity") } if ($null -eq $advisory.impactedComponents -or $advisory.impactedComponents.Count -eq 0) { Write-LogMessage -Type WARNING -Message "Advisory $($advisory.vmsaId) has no impactedComponents" } else { foreach ($component in @($advisory.impactedComponents)) { if ([String]::IsNullOrWhiteSpace($component.component)) { throw [System.InvalidOperationException]::new("Advisory $($advisory.vmsaId) component missing required field: component") } if (@($component.minimumVersions).Count -eq 0) { throw [System.InvalidOperationException]::new("Advisory $($advisory.vmsaId) component $($component.component) missing required field: minimumVersions") } if ($null -eq $component.fixedVersions -and $null -eq $component.kbArticles) { throw [System.InvalidOperationException]::new("Advisory $($advisory.vmsaId) component $($component.component) missing both fixedVersions and kbArticles") } } } } } function Select-AdvisoryByEnvironmentType { <# .SYNOPSIS Select advisories applicable to an environment type. .DESCRIPTION Returns advisories that contain at least one component applicable to the given environment type. Each environment type maps to a known set of component names; advisories whose components do not intersect that set are excluded. .PARAMETER Advisories Array of advisory documents returned by Get-SecurityAdvisory. .PARAMETER EnvironmentType Environment type: vcf5, vcf9, vsphere8, vvf9. .EXAMPLE $advisories = Get-SecurityAdvisory -FilePath $advisoryFilePath $vcf9Advisories = Select-AdvisoryByEnvironmentType -Advisories $advisories -EnvironmentType vcf9 .OUTPUTS [PSCustomObject[]] Applicable advisories for the environment type. .NOTES Pure filter function. Does not mutate any module-scope variables. Uses case-sensitive component name comparison to align with the Component Registry. #> [CmdletBinding()] [OutputType([Object[]])] Param ( [Parameter(Mandatory = $true)] [ValidateNotNull()] [Object[]]$Advisories, [Parameter(Mandatory = $true)] [ValidateSet('vcf5', 'vcf9', 'vsphere8', 'vvf9')] [String]$EnvironmentType ) $applicableAdvisories = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($advisory in $Advisories) { try { $advisory = ConvertFrom-AdvisoryDocument -Advisory $advisory } catch { Write-LogMessage -Type WARNING -Message "Skipping invalid advisory: $($_.Exception.Message)" continue } $isApplicable = $false foreach ($component in @($advisory.impactedComponents)) { $componentName = [String]$component.component switch ($EnvironmentType) { 'vcf5' { # ESX is an alias for ESXi; VCF Operations Workload Mobility is HCX. if (@('ESXi', 'ESX', 'vCenter', 'NSX', 'SDDC Manager', 'VCF Operations Workload Mobility') -contains $componentName) { $isApplicable = $true } } 'vcf9' { # ESX is an alias for ESXi; VCF Operations Workload Mobility is HCX. # Both current Broadcom UI names and older "VCF XXX" advisory names are included. if (@('ESXi', 'ESX', 'vCenter', 'NSX', 'SDDC Manager', 'VCF Operations', 'VCF Operations for Logs', 'VCF Operations for Networks', 'VCF Operations Workload Mobility', 'VCF Automation', 'VCF Services Runtime', 'Fleet Lifecycle', 'VCF Fleet Management', 'Identity Broker', 'VCF Identity', 'VCF Identity Broker', 'Salt Master', 'VCF Salt Master', 'Salt RaaS', 'VCF Salt RaaS', 'Software Depot', 'VCF Software Depot', 'SDDC Lifecycle', 'VCF SDDC Lifecycle', 'Telemetry', 'VCF Telemetry') -contains $componentName) { $isApplicable = $true } } 'vsphere8' { # ESX is an alias for ESXi. NSX is optional for standalone vSphere 8. if (@('ESXi', 'ESX', 'vCenter', 'NSX') -contains $componentName) { $isApplicable = $true } } 'vvf9' { # VVF base includes ESXi, vCenter, VCF Operations, and VCF Operations for Logs only. # NSX, VCF Automation, and VCF Operations for Networks are not part of the VVF base offer. # ESX is an alias for ESXi. if (@('ESXi', 'ESX', 'vCenter', 'VCF Operations', 'VCF Operations for Logs') -contains $componentName) { $isApplicable = $true } } } if ($isApplicable) { break } } if ($isApplicable) { $applicableAdvisories.Add($advisory) } } return @($applicableAdvisories) } function Select-AdvisoryByProductFamily { <# .SYNOPSIS Select advisories applicable to a product family. .DESCRIPTION Returns advisories that contain at least one component belonging to the specified product family. The family-to-component mapping is defined in $Script:PRODUCT_FAMILY_COMPONENTS and documented in ADVISORY_SCHEMA.md. Product families and their included components: VCF — ESXi, vCenter, NSX, SDDC Manager, VCF Operations, VCF Operations for Logs, VCF Operations for Networks, VCF Automation, VCF Services Runtime, Fleet Lifecycle, Identity Broker, Salt Master, Salt RaaS, Software Depot, SDDC Lifecycle, Telemetry (and legacy "VCF XXX" advisory names for each of the above) VVF — ESXi, vCenter, NSX, VCF Operations, VCF Operations for Logs, VCF Operations for Networks vSphere — ESXi, vCenter .PARAMETER Advisories Array of advisory objects as returned by Get-SecurityAdvisory. .PARAMETER ProductFamily Target product family: VCF, VVF, or vSphere. .EXAMPLE $advisories = Get-SecurityAdvisory -FilePath $advisoryFilePath $vcfAdvisories = Select-AdvisoryByProductFamily -Advisories $advisories -ProductFamily VCF .EXAMPLE $criticalVcf = Get-SecurityAdvisory -FilePath $advisoryFilePath | Where-Object { $_.severity -eq 'Critical' } | Select-AdvisoryByProductFamily -ProductFamily VCF .OUTPUTS [PSCustomObject[]] Advisories that contain at least one component in the product family. .NOTES Supports pipeline input via $Advisories. Logs the total input count and output count at INFO level. Uses case-sensitive component name comparison to align with the Component Registry. #> [CmdletBinding()] [OutputType([Object[]])] Param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [ValidateNotNull()] [Object[]]$Advisories, [Parameter(Mandatory = $true)] [ValidateSet('VCF', 'VVF', 'vSphere')] [String]$ProductFamily ) begin { $familyComponents = $Script:PRODUCT_FAMILY_COMPONENTS[$ProductFamily] $result = [System.Collections.Generic.List[PSCustomObject]]::new() $totalInput = 0 } process { foreach ($advisory in $Advisories) { $totalInput++ try { $advisory = ConvertFrom-AdvisoryDocument -Advisory $advisory } catch { Write-LogMessage -Type WARNING -Message "Skipping invalid advisory: $($_.Exception.Message)" continue } foreach ($component in @($advisory.impactedComponents)) { if ($familyComponents -contains [String]$component.component) { $result.Add($advisory) break } } } } end { Write-LogMessage -Type INFO -Message "Filtered advisories for product family '$ProductFamily': $($result.Count) applicable out of $totalInput" return @($result) } } function Select-AdvisoryByComponent { <# .SYNOPSIS Select advisories affecting one or more specific components. .DESCRIPTION Returns advisories that contain at least one component entry whose component name matches any of the supplied names. Component names must match the canonical values in the Component Registry (see ADVISORY_SCHEMA.md). Matching is case-sensitive to align with the registry. .PARAMETER Advisories Array of advisory objects as returned by Get-SecurityAdvisory. .PARAMETER Component One or more canonical component names to filter on (e.g. 'ESXi', 'vCenter', 'NSX'). .EXAMPLE $advisories = Get-SecurityAdvisory -FilePath $advisoryFilePath $esxiAdvisories = Select-AdvisoryByComponent -Advisories $advisories -Component 'ESXi' .EXAMPLE $infraAdvisories = Select-AdvisoryByComponent -Advisories $advisories -Component 'ESXi', 'vCenter', 'NSX' .OUTPUTS [PSCustomObject[]] Advisories that contain at least one of the requested components. .NOTES Supports pipeline input via $Advisories. Logs the total input count and output count at INFO level. Uses case-sensitive component name comparison to align with the Component Registry. #> [CmdletBinding()] [OutputType([Object[]])] Param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] [ValidateNotNull()] [Object[]]$Advisories, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [String[]]$Component ) begin { $result = [System.Collections.Generic.List[PSCustomObject]]::new() $totalInput = 0 } process { foreach ($advisory in $Advisories) { $totalInput++ try { $advisory = ConvertFrom-AdvisoryDocument -Advisory $advisory } catch { Write-LogMessage -Type WARNING -Message "Skipping invalid advisory: $($_.Exception.Message)" continue } foreach ($comp in @($advisory.impactedComponents)) { if ($Component -contains [String]$comp.component) { $result.Add($advisory) break } } } } end { $componentList = $Component -join ', ' Write-LogMessage -Type INFO -Message "Filtered advisories for component(s) [$componentList]: $($result.Count) applicable out of $totalInput" return @($result) } } #endregion |