Add patched version column to vulnerability summary with multi-range support (#5)

* Initial plan

* Initial plan for adding patched versions to vulnerability summary

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Add patched version column to vulnerability summary table

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Optimize API calls to use Set and Promise.all for better performance

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Remove type assertions and optimize ecosystem lookups with normalization

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Extract patch version type checking into helper function for clarity

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Support multiple version ranges per package in advisory lookup

Handle scenarios where the same package has multiple vulnerable version ranges
with different patched versions (e.g., GHSA-gwq6-fmvp-qp68 with .NET packages).

- Store all vulnerability entries with version ranges, not just one per ecosystem
- Implement version range matching to select correct patch version
- Match package by ecosystem, name, AND version range
- Add comprehensive test for multi-range scenario

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Fix first_patched_version extraction to handle string format

The GitHub Advisory API returns first_patched_version as a simple string
(e.g., "112.0.0"), not an object with an identifier field. Updated
extractPatchVersionId to handle string format primarily while maintaining
backward compatibility with object format.

- Fixed extractPatchVersionId to check for string first
- Updated test mocks to match actual API response format
- Added specific test for RestSharp GHSA-4rr6-2v9v-wcpc case
- Verified with actual API responses from multiple advisories

Fixes issue where patched versions were showing as "N/A" instead of
the actual version number.

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Rebuild dist with patched version fix

The previous fix to extractPatchVersionId wasn't deployed because the dist/
folder (compiled JavaScript) wasn't rebuilt. This commit rebuilds and packages
the action with npm run build && npm run package to include the fix.

Changes in dist/:
- Updated extractPatchVersionId to handle string format first
- Includes all async vulnerability summary logic with API calls
- Properly extracts patched versions from GitHub Advisory API

This should resolve the issue where patched versions showed as "N/A" in
actual GitHub Actions runs.

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

* Add comprehensive debug logging for patch version lookup

Added detailed debug logging to help troubleshoot patch version issues:
- Log when fetching advisory data from API
- Log number of vulnerability entries found
- Log each patch info entry added with details
- Log when no patch version is found
- Log during lookup phase with package details
- Log when patch version is found vs not found
- Log available entries when no match is found

This will make it much easier to diagnose issues in GitHub Actions debug mode.

Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: felickz <1760475+felickz@users.noreply.github.com>
This commit is contained in:
Copilot
2026-02-06 18:12:20 -05:00
committed by GitHub
parent 98884d411b
commit 2af9bac14d
7 changed files with 911 additions and 68 deletions

View File

@@ -193,7 +193,10 @@ async function run(): Promise<void> {
if (config.vulnerability_check) {
core.setOutput('vulnerable-changes', JSON.stringify(vulnerableChanges))
summary.addChangeVulnerabilitiesToSummary(vulnerableChanges, minSeverity)
await summary.addChangeVulnerabilitiesToSummary(
vulnerableChanges,
minSeverity
)
issueFound ||= await printVulnerabilitiesBlock(
vulnerableChanges,
minSeverity,

View File

@@ -2,7 +2,12 @@ import * as core from '@actions/core'
import {SummaryTableRow} from '@actions/core/lib/summary'
import {InvalidLicenseChanges, InvalidLicenseChangeTypes} from './licenses'
import {Change, Changes, ConfigurationOptions, Scorecard} from './schemas'
import {groupDependenciesByManifest, getManifestsSet, renderUrl} from './utils'
import {
groupDependenciesByManifest,
getManifestsSet,
renderUrl,
octokitClient
} from './utils'
const icons = {
check: '✅',
@@ -12,6 +17,62 @@ const icons = {
const MAX_SCANNED_FILES_BYTES = 1048576
// Helper to check if a version falls within a vulnerable range
// Supports basic semver comparisons like ">= 8.0.0, <= 8.0.20"
function versionInRange(version: string, range: string): boolean {
if (!version || !range) return false
// Parse version into comparable parts
const vParts = version.split('.').map(p => parseInt(p, 10))
// Handle range formats like ">= 8.0.0, <= 8.0.20"
const conditions = range.split(',').map(c => c.trim())
for (const condition of conditions) {
const match = condition.match(/([><=]+)\s*(\d+(?:\.\d+)*)/)
if (!match) continue
const [, operator, rangeVer] = match
const rParts = rangeVer.split('.').map(p => parseInt(p, 10))
// Compare versions part by part
let cmp = 0
for (let i = 0; i < Math.max(vParts.length, rParts.length); i++) {
const v = vParts[i] || 0
const r = rParts[i] || 0
if (v > r) {
cmp = 1
break
} else if (v < r) {
cmp = -1
break
}
}
// Check if condition is satisfied
if (operator === '>=' && cmp < 0) return false
if (operator === '>' && cmp <= 0) return false
if (operator === '<=' && cmp > 0) return false
if (operator === '<' && cmp >= 0) return false
if (operator === '=' && cmp !== 0) return false
}
return true
}
function extractPatchVersionId(patchData: unknown): string | null {
// Handle string format (current API response)
if (typeof patchData === 'string') return patchData
// Handle object format with identifier field (for backward compatibility)
if (patchData && typeof patchData === 'object' && 'identifier' in patchData) {
const id = (patchData as {identifier: unknown}).identifier
return typeof id === 'string' ? id : null
}
return null
}
// generates the DR report summary and caches it to the Action's core.summary.
// returns the DR summary string, ready to be posted as a PR comment if the
// final DR report is too large
@@ -132,18 +193,77 @@ function countScorecardWarnings(
)
}
export function addChangeVulnerabilitiesToSummary(
export async function addChangeVulnerabilitiesToSummary(
vulnerableChanges: Changes,
severity: string
): void {
): Promise<void> {
if (vulnerableChanges.length === 0) {
return
}
const rows: SummaryTableRow[] = []
const manifests = getManifestsSet(vulnerableChanges)
// Build set of unique advisories to query
const advisorySet = new Set<string>()
for (const pkg of vulnerableChanges) {
for (const vuln of pkg.vulnerabilities) {
advisorySet.add(vuln.advisory_ghsa_id)
}
}
// Query GitHub API for patch info in parallel
// Store all vulnerability entries (may be multiple per package with different ranges)
const patchInfo: Record<
string,
{eco: string; pkg: string; range: string; patch: string}[]
> = {}
const apiClient = octokitClient()
await Promise.all(
Array.from(advisorySet).map(async advId => {
try {
core.debug(`Fetching advisory data for ${advId}`)
const apiResult = await apiClient.request('GET /advisories/{ghsa_id}', {
ghsa_id: advId
})
patchInfo[advId] = []
const vulnList = apiResult.data.vulnerabilities || []
core.debug(
`Found ${vulnList.length} vulnerability entries for ${advId}`
)
for (const v of vulnList) {
if (v.package && v.package.ecosystem) {
const normalizedEco = v.package.ecosystem.toLowerCase()
const pkgName = v.package.name || ''
const vulnRange = v.vulnerable_version_range || ''
const patchVerId = extractPatchVersionId(v.first_patched_version)
if (patchVerId) {
patchInfo[advId].push({
eco: normalizedEco,
pkg: pkgName,
range: vulnRange,
patch: patchVerId
})
core.debug(
`Added patch info for ${pkgName} (${normalizedEco}): ${patchVerId} for range ${vulnRange}`
)
} else {
core.debug(
`No patch version found for ${pkgName} (${normalizedEco}) in ${advId}`
)
}
}
}
} catch (e) {
core.debug(`API call failed for ${advId}: ${e}`)
patchInfo[advId] = []
}
})
)
core.summary.addHeading('Vulnerabilities', 2)
for (const manifest of manifests) {
@@ -157,18 +277,49 @@ export function addChangeVulnerabilitiesToSummary(
previous_package === change.name &&
previous_version === change.version
// Look up patch version by matching package name, ecosystem, and version range
let patchVer = 'N/A'
const advData = patchInfo[vuln.advisory_ghsa_id]
if (advData && advData.length > 0) {
const normalizedEco = change.ecosystem.toLowerCase()
core.debug(
`Looking up patch for ${change.name}@${change.version} (${normalizedEco}) in ${vuln.advisory_ghsa_id}`
)
// Find matching entry by ecosystem, package name, and version range
const matchingEntry = advData.find(
entry =>
entry.eco === normalizedEco &&
entry.pkg === change.name &&
versionInRange(change.version, entry.range)
)
if (matchingEntry) {
patchVer = matchingEntry.patch
core.debug(
`Found patch version ${patchVer} for ${change.name}@${change.version}`
)
} else {
core.debug(
`No matching patch found for ${change.name}@${change.version}. Available entries: ${JSON.stringify(advData)}`
)
}
} else {
core.debug(`No advisory data available for ${vuln.advisory_ghsa_id}`)
}
if (!sameAsPrevious) {
rows.push([
renderUrl(change.source_repository_url, change.name),
change.version,
renderUrl(vuln.advisory_url, vuln.advisory_summary),
vuln.severity
vuln.severity,
patchVer
])
} else {
rows.push([
{data: '', colspan: '2'},
renderUrl(vuln.advisory_url, vuln.advisory_summary),
vuln.severity
vuln.severity,
patchVer
])
}
previous_package = change.name
@@ -180,7 +331,8 @@ export function addChangeVulnerabilitiesToSummary(
{data: 'Name', header: true},
{data: 'Version', header: true},
{data: 'Vulnerability', header: true},
{data: 'Severity', header: true}
{data: 'Severity', header: true},
{data: 'Patched Version', header: true}
],
...rows
])