@@ -27,6 +27,65 @@ const GHSA_PATTERN = /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/i
2727
2828type IdentifierType = 'uuid' | 'cve' | 'ghsa' | 'purl' | 'package'
2929
30+ /**
31+ * Parse a PURL to extract the package directory path and version
32+ * @example parsePurl('pkg:npm/[email protected] ') => { packageDir: 'lodash', version: '4.17.21' } 33+ * @example parsePurl('pkg:npm/@types/[email protected] ') => { packageDir: '@types /node', version: '20.0.0' } 34+ */
35+ function parsePurl ( purl : string ) : { packageDir : string ; version : string } | null {
36+ const match = purl . match ( / ^ p k g : n p m \/ ( .+ ) @ ( [ ^ @ ] + ) $ / )
37+ if ( ! match ) return null
38+ return { packageDir : match [ 1 ] , version : match [ 2 ] }
39+ }
40+
41+ /**
42+ * Check which PURLs from search results are actually installed in node_modules.
43+ * This is O(n) where n = number of unique packages in search results,
44+ * NOT O(m) where m = total packages in node_modules.
45+ */
46+ async function findInstalledPurls (
47+ cwd : string ,
48+ purls : string [ ] ,
49+ ) : Promise < Set < string > > {
50+ const nodeModulesPath = path . join ( cwd , 'node_modules' )
51+ const installedPurls = new Set < string > ( )
52+
53+ // Group PURLs by package directory to handle multiple versions of same package
54+ const packageVersions = new Map < string , Set < string > > ( )
55+ const purlLookup = new Map < string , string > ( ) // "packageDir@version" -> original purl
56+
57+ for ( const purl of purls ) {
58+ const parsed = parsePurl ( purl )
59+ if ( ! parsed ) continue
60+
61+ if ( ! packageVersions . has ( parsed . packageDir ) ) {
62+ packageVersions . set ( parsed . packageDir , new Set ( ) )
63+ }
64+ packageVersions . get ( parsed . packageDir ) ! . add ( parsed . version )
65+ purlLookup . set ( `${ parsed . packageDir } @${ parsed . version } ` , purl )
66+ }
67+
68+ // Check only the specific packages we need - O(n) filesystem operations
69+ for ( const [ packageDir , versions ] of packageVersions ) {
70+ const pkgJsonPath = path . join ( nodeModulesPath , packageDir , 'package.json' )
71+ try {
72+ const content = await fs . readFile ( pkgJsonPath , 'utf-8' )
73+ const pkg = JSON . parse ( content )
74+ if ( pkg . version && versions . has ( pkg . version ) ) {
75+ const key = `${ packageDir } @${ pkg . version } `
76+ const originalPurl = purlLookup . get ( key )
77+ if ( originalPurl ) {
78+ installedPurls . add ( originalPurl )
79+ }
80+ }
81+ } catch {
82+ // Package not found or invalid package.json - skip
83+ }
84+ }
85+
86+ return installedPurls
87+ }
88+
3089interface DownloadArgs {
3190 identifier : string
3291 org ?: string
@@ -390,14 +449,41 @@ async function downloadPatches(args: DownloadArgs): Promise<boolean> {
390449 return true
391450 }
392451
452+ // For CVE/GHSA searches, filter to only show patches for installed packages
453+ // Uses O(n) filesystem operations where n = unique packages in results,
454+ // NOT O(m) where m = all packages in node_modules
455+ let filteredResults = searchResults
456+ let notInstalledCount = 0
457+
458+ if ( idType === 'cve' || idType === 'ghsa' ) {
459+ console . log ( `Checking which packages are installed...` )
460+ const searchPurls = searchResults . map ( patch => patch . purl )
461+ const installedPurls = await findInstalledPurls ( cwd , searchPurls )
462+
463+ filteredResults = searchResults . filter ( patch => installedPurls . has ( patch . purl ) )
464+ notInstalledCount = searchResults . length - filteredResults . length
465+
466+ if ( filteredResults . length === 0 ) {
467+ console . log ( `No patches found for installed packages.` )
468+ if ( notInstalledCount > 0 ) {
469+ console . log ( ` (${ notInstalledCount } patch(es) exist for packages not installed in this project)` )
470+ }
471+ return true
472+ }
473+ }
474+
393475 // Filter patches based on tier access
394- const accessiblePatches = searchResults . filter (
476+ const accessiblePatches = filteredResults . filter (
395477 patch => patch . tier === 'free' || canAccessPaidPatches ,
396478 )
397- const inaccessibleCount = searchResults . length - accessiblePatches . length
479+ const inaccessibleCount = filteredResults . length - accessiblePatches . length
398480
399481 // Display search results
400- displaySearchResults ( searchResults , canAccessPaidPatches )
482+ displaySearchResults ( filteredResults , canAccessPaidPatches )
483+
484+ if ( notInstalledCount > 0 ) {
485+ console . log ( `Note: ${ notInstalledCount } patch(es) for packages not installed in this project were hidden.` )
486+ }
401487
402488 if ( inaccessibleCount > 0 ) {
403489 console . log (
0 commit comments