Vulnerability/Get-VulnerabilityPriority.ps1

function Get-VulnerabilityPriority {
    <#
    .SYNOPSIS
        Prioritizes assessment CVEs with EPSS and CISA KEV evidence.
    .DESCRIPTION
        Adds deterministic triage points to NVD and offline vulnerability findings.
        The heuristic combines a severity impact proxy, EPSS probability, and an
        asset multiplier. KEV membership overrides the score to 100/Critical.
        Missing evidence remains null and produces an honest Unscored state.
        These triage points are local queue-weighting policy, not a probability
        of exploitation or a measure of business risk.
    .PARAMETER InputObject
        Assessment result returned by Get-VulnerabilityAssessment.
    .PARAMETER AssetCriticality
        DomainController (1.25), Server (1.10), Workstation (1.00), or a custom
        finite numeric multiplier from 0.5 through 2.0. The default is 1.0.
    .PARAMETER EpssMode
        Auto uses a fresh full CSV cache, one API batch for small lookups, or the
        daily CSV for larger lookups. Api and Csv force the online refresh route.
    .PARAMETER CachePath
        Per-user feed cache directory. Defaults to LOCALAPPDATA on Windows.
        WSA_VULNERABILITY_PRIORITY_CACHE_PATH can override it for automation.
    .PARAMETER CacheMaxAgeHours
        Cache lifetime from 1 through 168 hours. An entry is stale at exactly
        this age. The default is 24 hours.
    .PARAMETER Offline
        Forbids all HTTP calls and uses only eligible cached data. This switch
        does not implicitly permit stale data.
    .PARAMETER AllowStaleCache
        Allows the last known-good expired cache when offline or after a failed
        refresh. Per-feed and per-row sources identify stale evidence.
    .PARAMETER ForceRefresh
        Attempts a live refresh even when a fresh cache exists. A valid fresh
        cache can still be used if that refresh fails. Offline remains authoritative.
    .EXAMPLE
        Get-VulnerabilityAssessment -ExactCVEMatch | Get-VulnerabilityPriority -AssetCriticality DomainController
    .EXAMPLE
        $assessment | Get-VulnerabilityPriority -Offline -AllowStaleCache -CachePath C:\AuditCache
    .OUTPUTS
        The original assessment with additive PrioritizedVulnerabilities,
        PrioritySummary, and PriorityMetadata properties. Existing NVD objects
        also receive additive EPSS, KEV, and priority fields.
    #>


    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [AllowNull()]
        [object]$InputObject,

        [Parameter()]
        [AllowNull()]
        [object]$AssetCriticality,

        [Parameter()]
        [ValidateSet('Auto', 'Api', 'Csv')]
        [string]$EpssMode = 'Auto',

        [Parameter()]
        [AllowNull()]
        [string]$CachePath,

        [Parameter()]
        [ValidateRange(1, 168)]
        [int]$CacheMaxAgeHours = 24,

        [Parameter()]
        [switch]$Offline,

        [Parameter()]
        [switch]$AllowStaleCache,

        [Parameter()]
        [switch]$ForceRefresh
    )

    begin {
        $resolvedAsset = Resolve-WsaAssetCriticality -AssetCriticality $AssetCriticality
        $resolvedCachePath = Get-WsaPriorityCacheRoot -CachePath $CachePath
        $inputItems = New-Object 'System.Collections.Generic.List[object]'
    }

    process {
        if ($null -ne $InputObject) {
            $inputItems.Add($InputObject)
        }
    }

    end {
        $states = New-Object 'System.Collections.Generic.List[object]'
        $uniqueCveSet = @{}

        foreach ($item in $inputItems) {
            $candidates = New-Object 'System.Collections.Generic.List[object]'
            $deduplicated = @{}
            $applications = @()
            if ((Test-WsaPriorityProperty -InputObject $item -Name 'Software') -and $null -ne $item.Software) {
                $applications = @($item.Software)
            }

            foreach ($application in $applications) {
                $software = ''
                if ((Test-WsaPriorityProperty -InputObject $application -Name 'Name') -and $null -ne $application.Name) {
                    $software = [string]$application.Name
                }
                elseif ((Test-WsaPriorityProperty -InputObject $application -Name 'DisplayName') -and $null -ne $application.DisplayName) {
                    $software = [string]$application.DisplayName
                }
                $version = $null
                if ((Test-WsaPriorityProperty -InputObject $application -Name 'Version') -and $null -ne $application.Version) {
                    $version = [string]$application.Version
                }
                elseif ((Test-WsaPriorityProperty -InputObject $application -Name 'DisplayVersion') -and $null -ne $application.DisplayVersion) {
                    $version = [string]$application.DisplayVersion
                }

                $collections = @(
                    [PSCustomObject]@{ Property = 'ExactCVEs'; MatchType = 'ExactCPE' },
                    [PSCustomObject]@{ Property = 'RealCVEs'; MatchType = 'Keyword' }
                )
                foreach ($collection in $collections) {
                    $propertyName = $collection.Property
                    if (-not (Test-WsaPriorityProperty -InputObject $application -Name $propertyName) -or $null -eq $application.$propertyName) {
                        continue
                    }
                    foreach ($cve in @($application.$propertyName)) {
                        if ($null -eq $cve) {
                            continue
                        }
                        $rawId = $null
                        if ((Test-WsaPriorityProperty -InputObject $cve -Name 'CVEId') -and $null -ne $cve.CVEId) {
                            $rawId = ([string]$cve.CVEId).Trim()
                        }
                        $validCve = Test-WsaCveId -CveId $rawId
                        $normalizedId = if ($validCve) { $rawId.ToUpperInvariant() } else { $rawId }
                        $severity = $null
                        if ((Test-WsaPriorityProperty -InputObject $cve -Name 'Severity') -and $null -ne $cve.Severity) {
                            $severity = [string]$cve.Severity
                        }
                        $cvssScore = $null
                        if ((Test-WsaPriorityProperty -InputObject $cve -Name 'CvssScore') -and $null -ne $cve.CvssScore) {
                            $cvssScore = $cve.CvssScore
                        }
                        $candidate = [PSCustomObject]@{
                            CVEId = $normalizedId
                            Software = $software
                            SoftwareVersion = $version
                            MatchType = $collection.MatchType
                            Severity = $severity
                            CvssScore = $cvssScore
                            SourceObjects = @($cve)
                        }

                        $dedupKey = 'NVD|' + $software.ToUpperInvariant() + '|' + $normalizedId
                        if ($deduplicated.ContainsKey($dedupKey)) {
                            $existing = $candidates[$deduplicated[$dedupKey]]
                            $existing.SourceObjects = @($existing.SourceObjects) + @($cve)
                            if ($collection.MatchType -eq 'ExactCPE' -and $existing.MatchType -ne 'ExactCPE') {
                                $existing.MatchType = 'ExactCPE'
                                $existing.Severity = $severity
                                $existing.CvssScore = $cvssScore
                                $existing.SoftwareVersion = $version
                            }
                        }
                        else {
                            $deduplicated[$dedupKey] = $candidates.Count
                            $candidates.Add($candidate)
                        }
                        if ($validCve) {
                            $uniqueCveSet[$normalizedId] = $true
                        }
                    }
                }
            }

            $offlineFindings = @()
            if ((Test-WsaPriorityProperty -InputObject $item -Name 'Vulnerabilities') -and $null -ne $item.Vulnerabilities) {
                $offlineFindings = @($item.Vulnerabilities)
            }
            foreach ($finding in $offlineFindings) {
                if ($null -eq $finding) {
                    continue
                }
                $software = ''
                if ((Test-WsaPriorityProperty -InputObject $finding -Name 'Software') -and $null -ne $finding.Software) {
                    $software = [string]$finding.Software
                }
                $version = $null
                if ((Test-WsaPriorityProperty -InputObject $finding -Name 'CurrentVersion') -and $null -ne $finding.CurrentVersion) {
                    $version = [string]$finding.CurrentVersion
                }
                $severity = $null
                if ((Test-WsaPriorityProperty -InputObject $finding -Name 'Severity') -and $null -ne $finding.Severity) {
                    $severity = [string]$finding.Severity
                }
                $cvssScore = $null
                if ((Test-WsaPriorityProperty -InputObject $finding -Name 'CvssScore') -and $null -ne $finding.CvssScore) {
                    $cvssScore = $finding.CvssScore
                }
                $cveText = $null
                if ((Test-WsaPriorityProperty -InputObject $finding -Name 'CVE') -and $null -ne $finding.CVE) {
                    $cveText = [string]$finding.CVE
                }
                $rawIds = if ($null -eq $cveText) { @('') } else { @($cveText.Split(',')) }
                foreach ($rawIdValue in $rawIds) {
                    $rawId = $rawIdValue.Trim()
                    $validCve = Test-WsaCveId -CveId $rawId
                    $normalizedId = if ($validCve) { $rawId.ToUpperInvariant() } else { $rawId }
                    $dedupKey = 'Offline|' + $software.ToUpperInvariant() + '|' + $normalizedId
                    if ($deduplicated.ContainsKey($dedupKey)) {
                        continue
                    }
                    $candidate = [PSCustomObject]@{
                        CVEId = $normalizedId
                        Software = $software
                        SoftwareVersion = $version
                        MatchType = 'OfflineVersion'
                        Severity = $severity
                        CvssScore = $cvssScore
                        SourceObjects = @()
                    }
                    $deduplicated[$dedupKey] = $candidates.Count
                    $candidates.Add($candidate)
                    if ($validCve) {
                        $uniqueCveSet[$normalizedId] = $true
                    }
                }
            }

            $states.Add([PSCustomObject]@{
                InputObject = $item
                Candidates = $candidates
            })
        }

        $uniqueCves = @($uniqueCveSet.Keys | Sort-Object)
        if ($uniqueCves.Count -gt 0) {
            try {
                $epssFeed = Get-WsaEpssFeed -CveIds $uniqueCves -CachePath $resolvedCachePath -Mode $EpssMode -CacheMaxAgeHours $CacheMaxAgeHours -Offline:$Offline -AllowStaleCache:$AllowStaleCache -ForceRefresh:$ForceRefresh
            }
            catch {
                $epssFeed = New-WsaEpssFeedResult -Error $_.Exception.Message
            }
            try {
                $kevFeed = Get-WsaKevFeed -CachePath $resolvedCachePath -CacheMaxAgeHours $CacheMaxAgeHours -Offline:$Offline -AllowStaleCache:$AllowStaleCache -ForceRefresh:$ForceRefresh
            }
            catch {
                $kevFeed = New-WsaKevFeedResult -Error $_.Exception.Message
            }
        }
        else {
            $epssFeed = New-WsaEpssFeedResult
            $kevFeed = New-WsaKevFeedResult
        }

        foreach ($state in $states) {
            $rows = New-Object 'System.Collections.Generic.List[object]'
            foreach ($candidate in $state.Candidates) {
                $validCve = Test-WsaCveId -CveId $candidate.CVEId
                $epssEntry = $null
                $epssSource = 'Unavailable'
                if ($validCve) {
                    if ($epssFeed.Entries.ContainsKey($candidate.CVEId)) {
                        $epssEntry = $epssFeed.Entries[$candidate.CVEId]
                        $epssSource = $epssFeed.Source
                    }
                    elseif ($epssFeed.Source -ne 'Unavailable' -and $epssFeed.CoveredCves.ContainsKey($candidate.CVEId)) {
                        $epssSource = 'NotScored'
                    }
                }

                $kevEntry = $null
                $kevSource = if ($validCve) { $kevFeed.Source } else { 'Unavailable' }
                $kevStatus = $null
                $kevDateAdded = $null
                $kevDueDate = $null
                $kevRequiredAction = $null
                $kevRansomware = $null
                $kevRansomwareStatus = 'Unavailable'
                $kevCatalogVersion = $null
                if ($validCve -and $kevSource -ne 'Unavailable') {
                    $kevCatalogVersion = $kevFeed.CatalogVersion
                    if ($kevFeed.Entries.ContainsKey($candidate.CVEId)) {
                        $kevEntry = $kevFeed.Entries[$candidate.CVEId]
                        $kevStatus = $true
                        $kevDateAdded = $kevEntry.DateAdded
                        $kevDueDate = $kevEntry.DueDate
                        $kevRequiredAction = $kevEntry.RequiredAction
                        $kevRansomwareStatus = $kevEntry.RansomwareStatus
                        if ($kevEntry.RansomwareStatus -eq 'Known') {
                            $kevRansomware = $true
                        }
                    }
                    else {
                        $kevStatus = $false
                        $kevRansomware = $false
                        $kevRansomwareStatus = 'NotListed'
                    }
                }

                $priority = Resolve-WsaPriorityScore -Severity $candidate.Severity -EpssScore $(if ($null -ne $epssEntry) { $epssEntry.EpssScore } else { $null }) -KevStatus $kevStatus -AssetMultiplier $resolvedAsset.AssetMultiplier -EpssSource $epssSource -KevSource $kevSource -ValidCve $validCve
                $row = [PSCustomObject]@{
                    CVEId = $candidate.CVEId
                    Software = $candidate.Software
                    SoftwareVersion = $candidate.SoftwareVersion
                    MatchType = $candidate.MatchType
                    Severity = $candidate.Severity
                    CvssScore = $candidate.CvssScore
                    EpssScore = if ($null -ne $epssEntry) { [double]$epssEntry.EpssScore } else { $null }
                    EpssPercentile = if ($null -ne $epssEntry) { [double]$epssEntry.EpssPercentile } else { $null }
                    EpssScoreDate = if ($null -ne $epssEntry) { $epssEntry.EpssScoreDate } else { $null }
                    KevStatus = $kevStatus
                    KevDateAdded = $kevDateAdded
                    KevDueDate = $kevDueDate
                    KevRequiredAction = $kevRequiredAction
                    KevRansomware = $kevRansomware
                    KevRansomwareStatus = $kevRansomwareStatus
                    KevCatalogVersion = $kevCatalogVersion
                    AssetCriticality = $resolvedAsset.AssetCriticality
                    AssetMultiplier = [double]$resolvedAsset.AssetMultiplier
                    PriorityScore = $priority.PriorityScore
                    PriorityTier = $priority.PriorityTier
                    PriorityStatus = $priority.PriorityStatus
                    UnscoredReason = $priority.UnscoredReason
                    ScoreSource = $priority.ScoreSource
                    EpssSource = $epssSource
                    KevSource = $kevSource
                }
                $rows.Add($row)

                foreach ($sourceObject in $candidate.SourceObjects) {
                    foreach ($property in $row.PSObject.Properties) {
                        if ($property.Name -notin @('CVEId', 'Severity', 'CvssScore')) {
                            $sourceObject | Add-Member -NotePropertyName $property.Name -NotePropertyValue $property.Value -Force
                        }
                    }
                }
            }

            $sortedRows = @($rows | Sort-Object `
                @{ Expression = { if ($_.KevStatus -eq $true) { 0 } else { 1 } } }, `
                @{ Expression = { if ($_.KevStatus -eq $true -and $null -ne $_.KevDueDate) { $_.KevDueDate } else { [DateTime]::MaxValue } } }, `
                @{ Expression = { if ($_.KevRansomware -eq $true) { 0 } else { 1 } } }, `
                @{ Expression = { if ($null -eq $_.PriorityScore) { 1 } else { 0 } } }, `
                @{ Expression = { $_.PriorityScore }; Descending = $true }, `
                @{ Expression = { if ($null -eq $_.EpssScore) { 1 } else { 0 } } }, `
                @{ Expression = { $_.EpssScore }; Descending = $true }, `
                @{ Expression = { $_.Software } }, `
                @{ Expression = { $_.CVEId } })

            $kevCves = @($sortedRows | Where-Object { $_.KevStatus -eq $true } | ForEach-Object { $_.CVEId } | Sort-Object -Unique)
            $summary = [PSCustomObject]@{
                TotalFindings = $sortedRows.Count
                UniqueCves = $uniqueCves.Count
                ScoredFindings = @($sortedRows | Where-Object { $_.PriorityStatus -eq 'Scored' }).Count
                UnscoredFindings = @($sortedRows | Where-Object { $_.PriorityStatus -eq 'Unscored' }).Count
                CriticalFindings = @($sortedRows | Where-Object { $_.PriorityTier -eq 'Critical' }).Count
                HighFindings = @($sortedRows | Where-Object { $_.PriorityTier -eq 'High' }).Count
                MediumFindings = @($sortedRows | Where-Object { $_.PriorityTier -eq 'Medium' }).Count
                LowFindings = @($sortedRows | Where-Object { $_.PriorityTier -eq 'Low' }).Count
                KevFindings = @($sortedRows | Where-Object { $_.KevStatus -eq $true }).Count
                UniqueKevCves = $kevCves.Count
                InvalidCveFindings = @($sortedRows | Where-Object { $_.UnscoredReason -eq 'InvalidCve' }).Count
            }
            $metadata = [PSCustomObject]@{
                GeneratedAtUtc = (Get-WsaPriorityUtcNow).ToUniversalTime().ToString('o', [Globalization.CultureInfo]::InvariantCulture)
                FormulaType = 'Deterministic triage-points heuristic'
                Formula = 'Round(Min(100, AssetMultiplier * (SeverityImpact + 60 * EPSS)), 2); KEV override = 100'
                AssetCriticality = $resolvedAsset.AssetCriticality
                AssetMultiplier = [double]$resolvedAsset.AssetMultiplier
                EpssMode = $EpssMode
                CachePath = $resolvedCachePath
                CacheMaxAgeHours = $CacheMaxAgeHours
                Offline = $Offline.IsPresent
                AllowStaleCache = $AllowStaleCache.IsPresent
                ForceRefresh = $ForceRefresh.IsPresent
                RequestedCves = @($uniqueCves)
                EpssSource = $epssFeed.Source
                EpssScoreDate = $epssFeed.ScoreDate
                EpssModelVersion = $epssFeed.ModelVersion
                EpssFetchedAtUtc = $epssFeed.FetchedAtUtc
                EpssError = $epssFeed.Error
                KevSource = $kevFeed.Source
                KevCatalogVersion = $kevFeed.CatalogVersion
                KevDateReleased = $kevFeed.DateReleased
                KevFetchedAtUtc = $kevFeed.FetchedAtUtc
                KevError = $kevFeed.Error
            }

            $state.InputObject | Add-Member -NotePropertyName PrioritizedVulnerabilities -NotePropertyValue @($sortedRows) -Force
            $state.InputObject | Add-Member -NotePropertyName PrioritySummary -NotePropertyValue $summary -Force
            $state.InputObject | Add-Member -NotePropertyName PriorityMetadata -NotePropertyValue $metadata -Force
            Write-Output $state.InputObject
        }
    }
}