@@ -5,6 +5,7 @@ import type { CommandModule } from 'yargs'
55import { PatchManifestSchema } from '../schema/manifest-schema.js'
66import {
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'
2021import { 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
2338const UUID_PATTERN =
2439 / ^ [ 0 - 9 a - f ] { 8 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 4 } - [ 0 - 9 a - f ] { 12 } $ / i
2540const CVE_PATTERN = / ^ C V E - \d { 4 } - \d + $ / i
2641const GHSA_PATTERN = / ^ G H S A - [ a - z 0 - 9 ] { 4 } - [ a - z 0 - 9 ] { 4 } - [ a - z 0 - 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+
2846type 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+
89190interface 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