Implement review fixes: semver library, scoping, case-insensitive matching, error logging, and configurable fail behavior (#7)

* Initial plan

* Implement PR review comment fixes: semver library, error handling, case-insensitive matching, and rows scoping

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

* Fix formatting and rebuild dist folder

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

* Fix fail-closed logic and remove redundant @types/semver

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

* Apply review feedback: fix empty range handling, add trimming, implement range check caching

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

* Apply review feedback: align fail-closed behavior for empty version, fix TypeScript typing, normalize cache keys

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

* Fix linter errors, optimize cache keys, and improve trimming logic

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

* Add fail-open option for patch selection and optimize with preTrimmed flag

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

* Enforce fail-closed with explicit validation, fix debug messages, normalize cache keys

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

* Fix unreachable ternary in debug message and eliminate duplicate trim operation

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

* Normalize eco comparison and add preNormalized option to avoid duplicate range conversion

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

* Remove unnecessary cache, fix function signature, and correct semver comment

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

* Make includePrerelease conditional based on version type to preserve range semantics

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

* Improve debug message to report both invalid version and range when applicable

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

* Convert to JSDoc, add explicit type annotation, and remove redundant initializer

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-08 16:05:04 -05:00
committed by GitHub
parent 2af9bac14d
commit ee66ea100d
5 changed files with 240 additions and 122 deletions

View File

@@ -8,6 +8,7 @@ import {
renderUrl,
octokitClient
} from './utils'
import * as semver from 'semver'
const icons = {
check: '✅',
@@ -17,47 +18,81 @@ 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
/**
* Helper to check if a version falls within a vulnerable range.
* Uses the `semver` library for proper prerelease handling and range parsing.
*
* @param version - The version to check (can be pre-trimmed).
* @param range - The version range to check against (can be pre-trimmed and/or pre-normalized).
* @param options - Configuration options.
* @param options.preTrimmed - If true, assumes inputs are already trimmed (optimization).
* @param options.preNormalized - If true, assumes range is already normalized (comma-to-space conversion done).
* @param options.failClosed - If true, returns true (vulnerable) on errors; if false, returns false (no match).
* @returns `true` if the version is considered within the vulnerable range (or on fail-closed), otherwise `false`.
*/
function versionInRange(
version: string | undefined,
range: string | undefined,
options: {
preTrimmed?: boolean
preNormalized?: boolean
failClosed?: boolean
} = {}
): boolean {
const {preTrimmed = false, preNormalized = false, failClosed = true} = options
// Parse version into comparable parts
const vParts = version.split('.').map(p => parseInt(p, 10))
// Trim inputs if not pre-trimmed
const trimmedVersion = preTrimmed ? version : version?.trim() || ''
const trimmedRange = preTrimmed ? range : range?.trim() || ''
// 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
}
if (!trimmedVersion) {
if (failClosed) {
core.debug(
`Empty or missing version for range "${range}". Treating as vulnerable (fail closed).`
)
}
// 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 failClosed
}
if (!trimmedRange) {
if (failClosed) {
core.debug(
`Empty or missing version range for version "${version}". Treating as vulnerable (fail closed).`
)
}
return failClosed
}
return true
// Convert GitHub API range format to semver-compatible format if not already normalized
// GitHub uses: ">= 8.0.0, <= 8.0.20"
// Semver accepts: ">= 8.0.0 <= 8.0.20" (operators may be followed by a space)
const semverRange = preNormalized
? trimmedRange
: trimmedRange.replace(/,\s*/g, ' ')
// Validate version and range explicitly to enforce fail-closed semantics
// semver.satisfies() typically returns false for invalid inputs without throwing
const validVersion = semver.valid(trimmedVersion)
const validRange = semver.validRange(semverRange)
if (!validVersion || !validRange) {
if (failClosed) {
const issues: string[] = []
if (!validVersion) issues.push('version')
if (!validRange) issues.push('version range')
core.debug(
`Invalid ${issues.join(' and ')}: version="${version}", range="${range}". Treating as vulnerable (fail closed).`
)
}
return failClosed
}
// Both version and range are valid; perform the satisfies check
// Only include prereleases when the version being checked is itself a prerelease
// to avoid changing range semantics globally
const isPrerelease = semver.prerelease(validVersion) !== null
return semver.satisfies(validVersion, validRange, {
includePrerelease: isPrerelease
})
}
function extractPatchVersionId(patchData: unknown): string | null {
@@ -201,7 +236,6 @@ export async function addChangeVulnerabilitiesToSummary(
return
}
const rows: SummaryTableRow[] = []
const manifests = getManifestsSet(vulnerableChanges)
// Build set of unique advisories to query
@@ -258,7 +292,8 @@ export async function addChangeVulnerabilitiesToSummary(
}
}
} catch (e) {
core.debug(`API call failed for ${advId}: ${e}`)
const errorMessage = e instanceof Error ? e.message : String(e)
core.debug(`API call failed for ${advId}: ${errorMessage}`)
patchInfo[advId] = []
}
})
@@ -267,6 +302,9 @@ export async function addChangeVulnerabilitiesToSummary(
core.summary.addHeading('Vulnerabilities', 2)
for (const manifest of manifests) {
// Create fresh rows array for each manifest to avoid accumulation
const rows: SummaryTableRow[] = []
for (const change of vulnerableChanges.filter(
pkg => pkg.manifest === manifest
)) {
@@ -279,27 +317,50 @@ export async function addChangeVulnerabilitiesToSummary(
// 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()
const advisoryEntries = patchInfo[vuln.advisory_ghsa_id]
if (advisoryEntries && advisoryEntries.length > 0) {
const ecoLowercase = change.ecosystem.toLowerCase()
const packageLowercase = change.name.toLowerCase()
const normalizedChangeVersion = change.version.trim()
core.debug(
`Looking up patch for ${change.name}@${change.version} (${normalizedEco}) in ${vuln.advisory_ghsa_id}`
`Looking up patch for ${change.name}@${change.version} (${ecoLowercase}) 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
// Find matching entry by ecosystem, package name (case-insensitive), and version range
let foundEntry:
| {eco: string; pkg: string; range: string; patch: string}
| undefined
for (const vulnEntry of advisoryEntries) {
if (vulnEntry.eco.toLowerCase() !== ecoLowercase) continue
if (vulnEntry.pkg.toLowerCase() !== packageLowercase) continue
// Normalize range once for both cache key and function call
const trimmedRangeOnce = vulnEntry.range.trim()
const normalizedRange = trimmedRangeOnce.replace(/,\s*/g, ' ')
// Use fail-open (failClosed: false) for patch selection to avoid
// incorrectly matching on invalid ranges
// Use preTrimmed and preNormalized optimizations since we've done both
const isInRange = versionInRange(
normalizedChangeVersion,
normalizedRange,
{preTrimmed: true, preNormalized: true, failClosed: false}
)
if (isInRange) {
foundEntry = vulnEntry
break
}
}
if (foundEntry) {
patchVer = foundEntry.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)}`
`No matching patch found for ${change.name}@${change.version}. Available entries: ${JSON.stringify(advisoryEntries)}`
)
}
} else {