11import type {
22 OsvQueryResponse ,
3+ OsvBatchResponse ,
34 OsvVulnerability ,
45 OsvSeverityLevel ,
56 VulnerabilitySummary ,
@@ -8,31 +9,83 @@ import type {
89 VulnerabilityTreeResult ,
910 DeprecatedPackageInfo ,
1011} from '#shared/types/dependency-analysis'
12+ import { mapWithConcurrency } from '#shared/utils/async'
1113import { resolveDependencyTree } from './dependency-resolver'
1214
13- /** Result of a single OSV query */
14- type OsvQueryResult = { status : 'ok' ; data : PackageVulnerabilityInfo | null } | { status : 'error' }
15+ /** Maximum concurrent requests for fetching vulnerability details */
16+ const OSV_DETAIL_CONCURRENCY = 25
17+
18+ /** Package info needed for OSV queries */
19+ interface PackageQueryInfo {
20+ name : string
21+ version : string
22+ depth : DependencyDepth
23+ path : string [ ]
24+ }
25+
26+ /**
27+ * Query OSV batch API to find which packages have vulnerabilities.
28+ * Returns indices of packages that have vulnerabilities (for follow-up detailed queries).
29+ * @see https://google.github.io/osv.dev/post-v1-querybatch/
30+ */
31+ async function queryOsvBatch (
32+ packages : PackageQueryInfo [ ] ,
33+ ) : Promise < { vulnerableIndices : number [ ] ; failed : boolean } > {
34+ if ( packages . length === 0 ) return { vulnerableIndices : [ ] , failed : false }
35+
36+ try {
37+ const response = await $fetch < OsvBatchResponse > ( 'https://api.osv.dev/v1/querybatch' , {
38+ method : 'POST' ,
39+ body : {
40+ queries : packages . map ( pkg => ( {
41+ package : { name : pkg . name , ecosystem : 'npm' } ,
42+ version : pkg . version ,
43+ } ) ) ,
44+ } ,
45+ } )
46+
47+ // Find indices of packages that have vulnerabilities
48+ const vulnerableIndices : number [ ] = [ ]
49+ for ( let i = 0 ; i < response . results . length ; i ++ ) {
50+ const result = response . results [ i ]
51+ if ( result ?. vulns && result . vulns . length > 0 ) {
52+ vulnerableIndices . push ( i )
53+ }
54+ // Warn if pagination token present (>1000 vulns for single query or >3000 total)
55+ // This is extremely unlikely for npm packages but log for visibility
56+ if ( result ?. next_page_token ) {
57+ // oxlint-disable-next-line no-console -- warn about paginated results
58+ console . warn (
59+ `[dep-analysis] OSV batch result has pagination token for package index ${ i } ` +
60+ `(${ packages [ i ] ?. name } @${ packages [ i ] ?. version } ) - some vulnerabilities may be missing` ,
61+ )
62+ }
63+ }
64+
65+ return { vulnerableIndices, failed : false }
66+ } catch ( error ) {
67+ // oxlint-disable-next-line no-console -- log OSV API failures for debugging
68+ console . warn ( `[dep-analysis] OSV batch query failed:` , error )
69+ return { vulnerableIndices : [ ] , failed : true }
70+ }
71+ }
1572
1673/**
17- * Query OSV for vulnerabilities in a package
74+ * Query OSV for full vulnerability details for a single package.
75+ * Only called for packages known to have vulnerabilities.
1876 */
19- async function queryOsv (
20- name : string ,
21- version : string ,
22- depth : DependencyDepth ,
23- path : string [ ] ,
24- ) : Promise < OsvQueryResult > {
77+ async function queryOsvDetails ( pkg : PackageQueryInfo ) : Promise < PackageVulnerabilityInfo | null > {
2578 try {
2679 const response = await $fetch < OsvQueryResponse > ( 'https://api.osv.dev/v1/query' , {
2780 method : 'POST' ,
2881 body : {
29- package : { name, ecosystem : 'npm' } ,
30- version,
82+ package : { name : pkg . name , ecosystem : 'npm' } ,
83+ version : pkg . version ,
3184 } ,
3285 } )
3386
3487 const vulns = response . vulns || [ ]
35- if ( vulns . length === 0 ) return { status : 'ok' , data : null }
88+ if ( vulns . length === 0 ) return null
3689
3790 const counts = { total : vulns . length , critical : 0 , high : 0 , moderate : 0 , low : 0 }
3891 const vulnerabilities : VulnerabilitySummary [ ] = [ ]
@@ -65,11 +118,18 @@ async function queryOsv(
65118 } )
66119 }
67120
68- return { status : 'ok' , data : { name, version, depth, path, vulnerabilities, counts } }
121+ return {
122+ name : pkg . name ,
123+ version : pkg . version ,
124+ depth : pkg . depth ,
125+ path : pkg . path ,
126+ vulnerabilities,
127+ counts,
128+ }
69129 } catch ( error ) {
70130 // oxlint-disable-next-line no-console -- log OSV API failures for debugging
71- console . warn ( `[dep-analysis] OSV query failed for ${ name } @${ version } :` , error )
72- return { status : 'error' }
131+ console . warn ( `[dep-analysis] OSV detail query failed for ${ pkg . name } @${ pkg . version } :` , error )
132+ return null
73133 }
74134}
75135
@@ -110,17 +170,24 @@ function getSeverityLevel(vuln: OsvVulnerability): OsvSeverityLevel {
110170
111171/**
112172 * Analyze entire dependency tree for vulnerabilities and deprecated packages.
173+ * Uses OSV batch API for efficient vulnerability discovery, then fetches
174+ * full details only for packages with known vulnerabilities.
113175 */
114176export const analyzeDependencyTree = defineCachedFunction (
115177 async ( name : string , version : string ) : Promise < VulnerabilityTreeResult > => {
116178 // Resolve all packages in the tree with depth tracking
117179 const resolved = await resolveDependencyTree ( name , version , { trackDepth : true } )
118180
119- // Convert to array for OSV querying
120- const packages = [ ...resolved . values ( ) ]
181+ // Convert to array with query info
182+ const packages : PackageQueryInfo [ ] = [ ...resolved . values ( ) ] . map ( pkg => ( {
183+ name : pkg . name ,
184+ version : pkg . version ,
185+ depth : pkg . depth ! ,
186+ path : pkg . path || [ ] ,
187+ } ) )
121188
122189 // Collect deprecated packages (no API call needed - already in packument data)
123- const deprecatedPackages : DeprecatedPackageInfo [ ] = packages
190+ const deprecatedPackages : DeprecatedPackageInfo [ ] = [ ... resolved . values ( ) ]
124191 . filter ( pkg => pkg . deprecated )
125192 . map ( pkg => ( {
126193 name : pkg . name ,
@@ -135,22 +202,27 @@ export const analyzeDependencyTree = defineCachedFunction(
135202 return depthOrder [ a . depth ] - depthOrder [ b . depth ]
136203 } )
137204
138- // Query OSV for all packages in parallel batches
139- const vulnerablePackages : PackageVulnerabilityInfo [ ] = [ ]
140- let failedQueries = 0
141- const batchSize = 10
205+ // Step 1: Use batch API to find which packages have vulnerabilities
206+ // This is much faster than individual queries - one request for all packages
207+ const { vulnerableIndices, failed : batchFailed } = await queryOsvBatch ( packages )
208+
209+ let vulnerablePackages : PackageVulnerabilityInfo [ ] = [ ]
210+ let failedQueries = batchFailed ? packages . length : 0
142211
143- for ( let i = 0 ; i < packages . length ; i += batchSize ) {
144- const batch = packages . slice ( i , i + batchSize )
145- const results = await Promise . all (
146- batch . map ( pkg => queryOsv ( pkg . name , pkg . version , pkg . depth ! , pkg . path || [ ] ) ) ,
212+ if ( ! batchFailed && vulnerableIndices . length > 0 ) {
213+ // Step 2: Fetch full vulnerability details only for packages with vulns
214+ // This is typically a small fraction of total packages
215+ const detailResults = await mapWithConcurrency (
216+ vulnerableIndices ,
217+ i => queryOsvDetails ( packages [ i ] ! ) ,
218+ OSV_DETAIL_CONCURRENCY ,
147219 )
148220
149- for ( const result of results ) {
150- if ( result . status === 'error' ) {
221+ for ( const result of detailResults ) {
222+ if ( result ) {
223+ vulnerablePackages . push ( result )
224+ } else {
151225 failedQueries ++
152- } else if ( result . data ) {
153- vulnerablePackages . push ( result . data )
154226 }
155227 }
156228 }
@@ -175,11 +247,11 @@ export const analyzeDependencyTree = defineCachedFunction(
175247 totalCounts . low += pkg . counts . low
176248 }
177249
178- // Log critical failures (>50% of queries failed)
179- if ( failedQueries > 0 && failedQueries > packages . length / 2 ) {
250+ // Log if batch query failed entirely
251+ if ( batchFailed ) {
180252 // oxlint-disable-next-line no-console -- critical error logging
181253 console . error (
182- `[dep-analysis] Critical: ${ failedQueries } / ${ packages . length } OSV queries failed for ${ name } @${ version } ` ,
254+ `[dep-analysis] Critical: OSV batch query failed for ${ name } @${ version } ( ${ packages . length } packages) ` ,
183255 )
184256 }
185257
@@ -197,6 +269,6 @@ export const analyzeDependencyTree = defineCachedFunction(
197269 maxAge : 60 * 60 ,
198270 swr : true ,
199271 name : 'dependency-analysis' ,
200- getKey : ( name : string , version : string ) => `v1 :${ name } @${ version } ` ,
272+ getKey : ( name : string , version : string ) => `v2 :${ name } @${ version } ` ,
201273 } ,
202274)
0 commit comments