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:
@@ -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,
|
||||
|
||||
166
src/summary.ts
166
src/summary.ts
@@ -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
|
||||
])
|
||||
|
||||
Reference in New Issue
Block a user