Skip to content

Commit 2187451

Browse files
committed
fix package search
1 parent a5b65c1 commit 2187451

File tree

4 files changed

+485
-10
lines changed

4 files changed

+485
-10
lines changed

src/commands/download.ts

Lines changed: 131 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,19 @@ import {
1313
cleanupUnusedBlobs,
1414
formatCleanupResult,
1515
} from '../utils/cleanup-blobs.js'
16+
import {
17+
enumerateNodeModules,
18+
type EnumeratedPackage,
19+
} from '../utils/enumerate-packages.js'
20+
import { fuzzyMatchPackages, isPurl } from '../utils/fuzzy-match.js'
1621

1722
// Identifier type patterns
1823
const UUID_PATTERN =
1924
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
2025
const CVE_PATTERN = /^CVE-\d{4}-\d+$/i
2126
const GHSA_PATTERN = /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/i
2227

23-
type IdentifierType = 'uuid' | 'cve' | 'ghsa'
28+
type IdentifierType = 'uuid' | 'cve' | 'ghsa' | 'purl' | 'package'
2429

2530
interface DownloadArgs {
2631
identifier: string
@@ -29,6 +34,7 @@ interface DownloadArgs {
2934
id?: boolean
3035
cve?: boolean
3136
ghsa?: boolean
37+
package?: boolean
3238
yes?: boolean
3339
'api-url'?: string
3440
'api-token'?: string
@@ -47,6 +53,9 @@ function detectIdentifierType(identifier: string): IdentifierType | null {
4753
if (GHSA_PATTERN.test(identifier)) {
4854
return 'ghsa'
4955
}
56+
if (isPurl(identifier)) {
57+
return 'purl'
58+
}
5059
return null
5160
}
5261

@@ -67,6 +76,44 @@ async function promptConfirmation(message: string): Promise<boolean> {
6776
})
6877
}
6978

79+
/**
80+
* Display enumerated packages and prompt user to select one
81+
*/
82+
async function promptSelectPackage(
83+
packages: EnumeratedPackage[],
84+
): Promise<EnumeratedPackage | null> {
85+
console.log('\nMatching packages found:\n')
86+
87+
for (let i = 0; i < packages.length; i++) {
88+
const pkg = packages[i]
89+
const displayName = pkg.namespace
90+
? `${pkg.namespace}/${pkg.name}`
91+
: pkg.name
92+
console.log(` ${i + 1}. ${displayName}@${pkg.version}`)
93+
console.log(` PURL: ${pkg.purl}`)
94+
}
95+
96+
const rl = readline.createInterface({
97+
input: process.stdin,
98+
output: process.stdout,
99+
})
100+
101+
return new Promise(resolve => {
102+
rl.question(
103+
`\nSelect a package (1-${packages.length}) or 0 to cancel: `,
104+
answer => {
105+
rl.close()
106+
const selection = parseInt(answer, 10)
107+
if (isNaN(selection) || selection < 1 || selection > packages.length) {
108+
resolve(null)
109+
} else {
110+
resolve(packages[selection - 1])
111+
}
112+
},
113+
)
114+
})
115+
}
116+
70117
/**
71118
* Display search results to the user
72119
*/
@@ -163,6 +210,7 @@ async function downloadPatches(args: DownloadArgs): Promise<boolean> {
163210
id: forceId,
164211
cve: forceCve,
165212
ghsa: forceGhsa,
213+
package: forcePackage,
166214
yes: skipConfirmation,
167215
'api-url': apiUrl,
168216
'api-token': apiToken,
@@ -197,15 +245,19 @@ async function downloadPatches(args: DownloadArgs): Promise<boolean> {
197245
idType = 'cve'
198246
} else if (forceGhsa) {
199247
idType = 'ghsa'
248+
} else if (forcePackage) {
249+
// --package flag forces package search (fuzzy match against node_modules)
250+
idType = 'package'
200251
} else {
201252
const detectedType = detectIdentifierType(identifier)
202253
if (!detectedType) {
203-
throw new Error(
204-
`Unrecognized identifier format: ${identifier}. Expected UUID, CVE ID (CVE-YYYY-NNNNN), or GHSA ID (GHSA-xxxx-xxxx-xxxx).`,
205-
)
254+
// If not recognized as UUID/CVE/GHSA/PURL, assume it's a package name search
255+
idType = 'package'
256+
console.log(`Treating "${identifier}" as a package name search`)
257+
} else {
258+
idType = detectedType
259+
console.log(`Detected identifier type: ${idType}`)
206260
}
207-
idType = detectedType
208-
console.log(`Detected identifier type: ${idType}`)
209261
}
210262

211263
// For UUID, directly fetch and download the patch
@@ -251,7 +303,7 @@ async function downloadPatches(args: DownloadArgs): Promise<boolean> {
251303
return true
252304
}
253305

254-
// For CVE/GHSA/package, first search then download
306+
// For CVE/GHSA/PURL/package, first search then download
255307
let searchResponse: SearchResponse
256308

257309
switch (idType) {
@@ -265,6 +317,61 @@ async function downloadPatches(args: DownloadArgs): Promise<boolean> {
265317
searchResponse = await apiClient.searchPatchesByGHSA(effectiveOrgSlug, identifier)
266318
break
267319
}
320+
case 'purl': {
321+
console.log(`Searching patches for PURL: ${identifier}`)
322+
searchResponse = await apiClient.searchPatchesByPackage(effectiveOrgSlug, identifier)
323+
break
324+
}
325+
case 'package': {
326+
// Enumerate packages from node_modules and fuzzy match
327+
console.log(`Enumerating packages in ${cwd}...`)
328+
const packages = await enumerateNodeModules(cwd)
329+
330+
if (packages.length === 0) {
331+
console.log('No packages found in node_modules. Run npm/yarn/pnpm install first.')
332+
return true
333+
}
334+
335+
console.log(`Found ${packages.length} packages in node_modules`)
336+
337+
// Fuzzy match against the identifier
338+
const matches = fuzzyMatchPackages(identifier, packages)
339+
340+
if (matches.length === 0) {
341+
console.log(`No packages matching "${identifier}" found in node_modules.`)
342+
return true
343+
}
344+
345+
let selectedPackage: EnumeratedPackage
346+
347+
if (matches.length === 1) {
348+
// Single match, use it directly
349+
selectedPackage = matches[0]
350+
console.log(`Found exact match: ${selectedPackage.purl}`)
351+
} else {
352+
// Multiple matches, prompt user to select
353+
if (skipConfirmation) {
354+
// With --yes, use the best match (first result)
355+
selectedPackage = matches[0]
356+
console.log(`Using best match: ${selectedPackage.purl}`)
357+
} else {
358+
const selected = await promptSelectPackage(matches)
359+
if (!selected) {
360+
console.log('No package selected. Download cancelled.')
361+
return true
362+
}
363+
selectedPackage = selected
364+
}
365+
}
366+
367+
// Search for patches using the selected package's PURL
368+
console.log(`Searching patches for package: ${selectedPackage.purl}`)
369+
searchResponse = await apiClient.searchPatchesByPackage(
370+
effectiveOrgSlug,
371+
selectedPackage.purl,
372+
)
373+
break
374+
}
268375
default:
269376
throw new Error(`Unknown identifier type: ${idType}`)
270377
}
@@ -381,7 +488,7 @@ export const downloadCommand: CommandModule<{}, DownloadArgs> = {
381488
return yargs
382489
.positional('identifier', {
383490
describe:
384-
'Patch identifier (UUID, CVE ID, or GHSA ID)',
491+
'Patch identifier (UUID, CVE ID, GHSA ID, PURL, or package name)',
385492
type: 'string',
386493
demandOption: true,
387494
})
@@ -405,6 +512,12 @@ export const downloadCommand: CommandModule<{}, DownloadArgs> = {
405512
type: 'boolean',
406513
default: false,
407514
})
515+
.option('package', {
516+
alias: 'p',
517+
describe: 'Force identifier to be treated as a package name (fuzzy matches against node_modules)',
518+
type: 'boolean',
519+
default: false,
520+
})
408521
.option('yes', {
409522
alias: 'y',
410523
describe: 'Skip confirmation prompt for multiple patches',
@@ -432,6 +545,14 @@ export const downloadCommand: CommandModule<{}, DownloadArgs> = {
432545
'$0 download GHSA-jfhm-5ghh-2f97',
433546
'Download free patches for a GHSA (no auth required)',
434547
)
548+
.example(
549+
'$0 download pkg:npm/[email protected]',
550+
'Download patches for a specific package version by PURL',
551+
)
552+
.example(
553+
'$0 download lodash --package',
554+
'Search for patches by package name (fuzzy matches node_modules)',
555+
)
435556
.example(
436557
'$0 download 12345678-1234-1234-1234-123456789abc --org myorg',
437558
'Download a patch by UUID (requires SOCKET_API_TOKEN)',
@@ -442,12 +563,12 @@ export const downloadCommand: CommandModule<{}, DownloadArgs> = {
442563
)
443564
.check(argv => {
444565
// Ensure only one type flag is set
445-
const typeFlags = [argv.id, argv.cve, argv.ghsa].filter(
566+
const typeFlags = [argv.id, argv.cve, argv.ghsa, argv.package].filter(
446567
Boolean,
447568
)
448569
if (typeFlags.length > 1) {
449570
throw new Error(
450-
'Only one of --id, --cve, or --ghsa can be specified',
571+
'Only one of --id, --cve, --ghsa, or --package can be specified',
451572
)
452573
}
453574
return true

src/utils/api-client.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,28 @@ export class APIClient {
193193
return result ?? { patches: [], canAccessPaidPatches: false }
194194
}
195195

196+
/**
197+
* Search patches by package PURL
198+
* Returns lightweight search results (no blob content)
199+
*
200+
* The PURL must be a valid Package URL starting with "pkg:"
201+
* Examples:
202+
* - pkg:npm/[email protected]
203+
* - pkg:npm/@types/node
204+
* - pkg:pypi/[email protected]
205+
*/
206+
async searchPatchesByPackage(
207+
orgSlug: string | null,
208+
purl: string,
209+
): Promise<SearchResponse> {
210+
// Public proxy uses simpler URL structure (no org slug needed)
211+
const path = this.usePublicProxy
212+
? `/by-package/${encodeURIComponent(purl)}`
213+
: `/v0/orgs/${orgSlug}/patches/by-package/${encodeURIComponent(purl)}`
214+
const result = await this.get<SearchResponse>(path)
215+
return result ?? { patches: [], canAccessPaidPatches: false }
216+
}
217+
196218
}
197219

198220
/**

0 commit comments

Comments
 (0)