Skip to content

Commit aafb172

Browse files
Filter package search to only show packages with available patches (#16)
- Add pre-flight patch availability check before prompting user to select - Only display packages that have patches with CVE/GHSA fixes - Show CVE/GHSA IDs and severity info in package selection list - Limit API queries to 15 packages max (sorted by name length) - Add progress indicator while checking patch availability - Skip packages without accessible patches to avoid user confusion 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <[email protected]>
1 parent e8e3606 commit aafb172

File tree

1 file changed

+201
-22
lines changed

1 file changed

+201
-22
lines changed

src/commands/download.ts

Lines changed: 201 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { CommandModule } from 'yargs'
55
import { PatchManifestSchema } from '../schema/manifest-schema.js'
66
import {
77
getAPIClientFromEnv,
8+
type APIClient,
89
type PatchResponse,
910
type PatchSearchResult,
1011
type SearchResponse,
@@ -19,12 +20,29 @@ import {
1920
} from '../utils/enumerate-packages.js'
2021
import { fuzzyMatchPackages, isPurl } from '../utils/fuzzy-match.js'
2122

23+
/**
24+
* Represents a package that has available patches with CVE information
25+
*/
26+
interface PackageWithPatchInfo extends EnumeratedPackage {
27+
/** Available patches for this package */
28+
patches: PatchSearchResult[]
29+
/** Whether user can access paid patches */
30+
canAccessPaidPatches: boolean
31+
/** CVE IDs that this package's patches address */
32+
cveIds: string[]
33+
/** GHSA IDs that this package's patches address */
34+
ghsaIds: string[]
35+
}
36+
2237
// Identifier type patterns
2338
const UUID_PATTERN =
2439
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
2540
const CVE_PATTERN = /^CVE-\d{4}-\d+$/i
2641
const GHSA_PATTERN = /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/i
2742

43+
// Maximum number of packages to check for patches (to limit API queries)
44+
const MAX_PACKAGES_TO_CHECK = 15
45+
2846
type IdentifierType = 'uuid' | 'cve' | 'ghsa' | 'purl' | 'package'
2947

3048
/**
@@ -86,6 +104,89 @@ async function findInstalledPurls(
86104
return installedPurls
87105
}
88106

107+
/**
108+
* Check which packages have available patches with CVE fixes.
109+
* Queries the API for each package and returns only those with patches.
110+
*
111+
* @param apiClient - API client to use for queries
112+
* @param orgSlug - Organization slug (or null for public proxy)
113+
* @param packages - Packages to check
114+
* @param onProgress - Optional callback for progress updates
115+
* @returns Packages that have available patches with CVE info
116+
*/
117+
async function findPackagesWithPatches(
118+
apiClient: APIClient,
119+
orgSlug: string | null,
120+
packages: EnumeratedPackage[],
121+
onProgress?: (checked: number, total: number, current: string) => void,
122+
): Promise<PackageWithPatchInfo[]> {
123+
const packagesWithPatches: PackageWithPatchInfo[] = []
124+
125+
for (let i = 0; i < packages.length; i++) {
126+
const pkg = packages[i]
127+
const displayName = pkg.namespace
128+
? `${pkg.namespace}/${pkg.name}`
129+
: pkg.name
130+
131+
if (onProgress) {
132+
onProgress(i + 1, packages.length, displayName)
133+
}
134+
135+
try {
136+
const searchResponse = await apiClient.searchPatchesByPackage(
137+
orgSlug,
138+
pkg.purl,
139+
)
140+
141+
const { patches, canAccessPaidPatches } = searchResponse
142+
143+
// Filter to only accessible patches
144+
const accessiblePatches = patches.filter(
145+
patch => patch.tier === 'free' || canAccessPaidPatches,
146+
)
147+
148+
if (accessiblePatches.length === 0) {
149+
continue
150+
}
151+
152+
// Extract CVE and GHSA IDs from patches
153+
const cveIds = new Set<string>()
154+
const ghsaIds = new Set<string>()
155+
156+
for (const patch of accessiblePatches) {
157+
for (const [vulnId, vulnInfo] of Object.entries(patch.vulnerabilities)) {
158+
// Check if the vulnId itself is a GHSA
159+
if (GHSA_PATTERN.test(vulnId)) {
160+
ghsaIds.add(vulnId)
161+
}
162+
// Add all CVEs associated with this vulnerability
163+
for (const cve of vulnInfo.cves) {
164+
cveIds.add(cve)
165+
}
166+
}
167+
}
168+
169+
// Only include packages that have CVE fixes
170+
if (cveIds.size === 0 && ghsaIds.size === 0) {
171+
continue
172+
}
173+
174+
packagesWithPatches.push({
175+
...pkg,
176+
patches: accessiblePatches,
177+
canAccessPaidPatches,
178+
cveIds: Array.from(cveIds).sort(),
179+
ghsaIds: Array.from(ghsaIds).sort(),
180+
})
181+
} catch {
182+
// Skip packages that fail API lookup (likely network issues)
183+
continue
184+
}
185+
}
186+
187+
return packagesWithPatches
188+
}
189+
89190
interface DownloadArgs {
90191
identifier: string
91192
org?: string
@@ -136,20 +237,40 @@ async function promptConfirmation(message: string): Promise<boolean> {
136237
}
137238

138239
/**
139-
* Display enumerated packages and prompt user to select one
240+
* Display packages with available patches and CVE info, prompt user to select one
140241
*/
141-
async function promptSelectPackage(
142-
packages: EnumeratedPackage[],
143-
): Promise<EnumeratedPackage | null> {
144-
console.log('\nMatching packages found:\n')
242+
async function promptSelectPackageWithPatches(
243+
packages: PackageWithPatchInfo[],
244+
): Promise<PackageWithPatchInfo | null> {
245+
console.log('\nPackages with available security patches:\n')
145246

146247
for (let i = 0; i < packages.length; i++) {
147248
const pkg = packages[i]
148249
const displayName = pkg.namespace
149250
? `${pkg.namespace}/${pkg.name}`
150251
: pkg.name
252+
253+
// Build vulnerability summary
254+
const vulnIds = [...pkg.cveIds, ...pkg.ghsaIds]
255+
const vulnSummary = vulnIds.length > 3
256+
? `${vulnIds.slice(0, 3).join(', ')} (+${vulnIds.length - 3} more)`
257+
: vulnIds.join(', ')
258+
259+
// Count patches and show severity info
260+
const severities = new Set<string>()
261+
for (const patch of pkg.patches) {
262+
for (const vuln of Object.values(patch.vulnerabilities)) {
263+
severities.add(vuln.severity)
264+
}
265+
}
266+
const severityList = Array.from(severities).sort((a, b) => {
267+
const order = ['critical', 'high', 'medium', 'low']
268+
return order.indexOf(a.toLowerCase()) - order.indexOf(b.toLowerCase())
269+
})
270+
151271
console.log(` ${i + 1}. ${displayName}@${pkg.version}`)
152-
console.log(` PURL: ${pkg.purl}`)
272+
console.log(` Patches: ${pkg.patches.length} | Severity: ${severityList.join(', ')}`)
273+
console.log(` Fixes: ${vulnSummary}`)
153274
}
154275

155276
const rl = readline.createInterface({
@@ -401,27 +522,86 @@ async function downloadPatches(args: DownloadArgs): Promise<boolean> {
401522
console.log(`Found ${packages.length} packages in node_modules`)
402523

403524
// Fuzzy match against the identifier
404-
const matches = fuzzyMatchPackages(identifier, packages)
525+
let matches = fuzzyMatchPackages(identifier, packages)
405526

406527
if (matches.length === 0) {
407528
console.log(`No packages matching "${identifier}" found in node_modules.`)
408529
return true
409530
}
410531

411-
let selectedPackage: EnumeratedPackage
532+
// Sort by package name length (shorter names are typically more relevant/common)
533+
// and truncate to limit API queries
534+
let truncatedCount = 0
535+
if (matches.length > MAX_PACKAGES_TO_CHECK) {
536+
// Sort by full name length (namespace/name) - shorter = more relevant
537+
matches = matches.sort((a, b) => {
538+
const aFullName = a.namespace ? `${a.namespace}/${a.name}` : a.name
539+
const bFullName = b.namespace ? `${b.namespace}/${b.name}` : b.name
540+
return aFullName.length - bFullName.length
541+
})
542+
truncatedCount = matches.length - MAX_PACKAGES_TO_CHECK
543+
matches = matches.slice(0, MAX_PACKAGES_TO_CHECK)
544+
console.log(`Found ${matches.length + truncatedCount} matching package(s), checking top ${MAX_PACKAGES_TO_CHECK} by name length...`)
545+
} else {
546+
console.log(`Found ${matches.length} matching package(s), checking for available patches...`)
547+
}
548+
549+
// Check which packages have available patches with CVE fixes
550+
const packagesWithPatches = await findPackagesWithPatches(
551+
apiClient,
552+
effectiveOrgSlug,
553+
matches,
554+
(checked, total, current) => {
555+
// Clear line and show progress
556+
process.stdout.write(`\r Checking ${checked}/${total}: ${current}`.padEnd(80))
557+
},
558+
)
559+
// Clear the progress line
560+
process.stdout.write('\r' + ' '.repeat(80) + '\r')
561+
562+
if (packagesWithPatches.length === 0) {
563+
console.log(`No patches with CVE fixes found for packages matching "${identifier}".`)
564+
const checkedCount = matches.length
565+
if (checkedCount > 0) {
566+
console.log(` (${checkedCount} package(s) checked but none have available patches)`)
567+
}
568+
if (truncatedCount > 0) {
569+
console.log(` (${truncatedCount} additional match(es) not checked - try a more specific search)`)
570+
}
571+
return true
572+
}
573+
574+
const skippedCount = matches.length - packagesWithPatches.length
575+
if (skippedCount > 0 || truncatedCount > 0) {
576+
let note = `Found ${packagesWithPatches.length} package(s) with available patches`
577+
if (skippedCount > 0) {
578+
note += ` (${skippedCount} without patches hidden)`
579+
}
580+
if (truncatedCount > 0) {
581+
note += ` (${truncatedCount} additional match(es) not checked)`
582+
}
583+
console.log(note)
584+
}
412585

413-
if (matches.length === 1) {
414-
// Single match, use it directly
415-
selectedPackage = matches[0]
416-
console.log(`Found exact match: ${selectedPackage.purl}`)
586+
let selectedPackage: PackageWithPatchInfo
587+
588+
if (packagesWithPatches.length === 1) {
589+
// Single match with patches, use it directly
590+
selectedPackage = packagesWithPatches[0]
591+
const displayName = selectedPackage.namespace
592+
? `${selectedPackage.namespace}/${selectedPackage.name}`
593+
: selectedPackage.name
594+
console.log(`Found: ${displayName}@${selectedPackage.version}`)
595+
console.log(` Patches: ${selectedPackage.patches.length}`)
596+
console.log(` Fixes: ${[...selectedPackage.cveIds, ...selectedPackage.ghsaIds].join(', ')}`)
417597
} else {
418-
// Multiple matches, prompt user to select
598+
// Multiple matches with patches, prompt user to select
419599
if (skipConfirmation) {
420-
// With --yes, use the best match (first result)
421-
selectedPackage = matches[0]
600+
// With --yes, use the first result (best match with patches)
601+
selectedPackage = packagesWithPatches[0]
422602
console.log(`Using best match: ${selectedPackage.purl}`)
423603
} else {
424-
const selected = await promptSelectPackage(matches)
604+
const selected = await promptSelectPackageWithPatches(packagesWithPatches)
425605
if (!selected) {
426606
console.log('No package selected. Download cancelled.')
427607
return true
@@ -430,12 +610,11 @@ async function downloadPatches(args: DownloadArgs): Promise<boolean> {
430610
}
431611
}
432612

433-
// Search for patches using the selected package's PURL
434-
console.log(`Searching patches for package: ${selectedPackage.purl}`)
435-
searchResponse = await apiClient.searchPatchesByPackage(
436-
effectiveOrgSlug,
437-
selectedPackage.purl,
438-
)
613+
// Use pre-fetched patch info directly
614+
searchResponse = {
615+
patches: selectedPackage.patches,
616+
canAccessPaidPatches: selectedPackage.canAccessPaidPatches,
617+
}
439618
break
440619
}
441620
default:

0 commit comments

Comments
 (0)