11import type {
22 OsvQueryResponse ,
3+ OsvBatchResponse ,
34 OsvVulnerability ,
45 OsvSeverityLevel ,
56 VulnerabilitySummary ,
@@ -10,29 +11,68 @@ import type {
1011} from '#shared/types/dependency-analysis'
1112import { resolveDependencyTree } from './dependency-resolver'
1213
13- /** Result of a single OSV query */
14- type OsvQueryResult = { status : 'ok' ; data : PackageVulnerabilityInfo | null } | { status : 'error' }
14+ /** Package info needed for OSV queries */
15+ interface PackageQueryInfo {
16+ name : string
17+ version : string
18+ depth : DependencyDepth
19+ path : string [ ]
20+ }
1521
1622/**
17- * Query OSV for vulnerabilities in a package
23+ * Query OSV batch API to find which packages have vulnerabilities.
24+ * Returns indices of packages that have vulnerabilities (for follow-up detailed queries).
25+ * @see https://google.github.io/osv.dev/post-v1-querybatch/
1826 */
19- async function queryOsv (
20- name : string ,
21- version : string ,
22- depth : DependencyDepth ,
23- path : string [ ] ,
24- ) : Promise < OsvQueryResult > {
27+ async function queryOsvBatch (
28+ packages : PackageQueryInfo [ ] ,
29+ ) : Promise < { vulnerableIndices : number [ ] ; failed : boolean } > {
30+ if ( packages . length === 0 ) return { vulnerableIndices : [ ] , failed : false }
31+
32+ try {
33+ const response = await $fetch < OsvBatchResponse > ( 'https://api.osv.dev/v1/querybatch' , {
34+ method : 'POST' ,
35+ body : {
36+ queries : packages . map ( pkg => ( {
37+ package : { name : pkg . name , ecosystem : 'npm' } ,
38+ version : pkg . version ,
39+ } ) ) ,
40+ } ,
41+ } )
42+
43+ // Find indices of packages that have vulnerabilities
44+ const vulnerableIndices : number [ ] = [ ]
45+ for ( let i = 0 ; i < response . results . length ; i ++ ) {
46+ const result = response . results [ i ]
47+ if ( result ?. vulns && result . vulns . length > 0 ) {
48+ vulnerableIndices . push ( i )
49+ }
50+ }
51+
52+ return { vulnerableIndices, failed : false }
53+ } catch ( error ) {
54+ // oxlint-disable-next-line no-console -- log OSV API failures for debugging
55+ console . warn ( `[dep-analysis] OSV batch query failed:` , error )
56+ return { vulnerableIndices : [ ] , failed : true }
57+ }
58+ }
59+
60+ /**
61+ * Query OSV for full vulnerability details for a single package.
62+ * Only called for packages known to have vulnerabilities.
63+ */
64+ async function queryOsvDetails ( pkg : PackageQueryInfo ) : Promise < PackageVulnerabilityInfo | null > {
2565 try {
2666 const response = await $fetch < OsvQueryResponse > ( 'https://api.osv.dev/v1/query' , {
2767 method : 'POST' ,
2868 body : {
29- package : { name, ecosystem : 'npm' } ,
30- version,
69+ package : { name : pkg . name , ecosystem : 'npm' } ,
70+ version : pkg . version ,
3171 } ,
3272 } )
3373
3474 const vulns = response . vulns || [ ]
35- if ( vulns . length === 0 ) return { status : 'ok' , data : null }
75+ if ( vulns . length === 0 ) return null
3676
3777 const counts = { total : vulns . length , critical : 0 , high : 0 , moderate : 0 , low : 0 }
3878 const vulnerabilities : VulnerabilitySummary [ ] = [ ]
@@ -65,11 +105,18 @@ async function queryOsv(
65105 } )
66106 }
67107
68- return { status : 'ok' , data : { name, version, depth, path, vulnerabilities, counts } }
108+ return {
109+ name : pkg . name ,
110+ version : pkg . version ,
111+ depth : pkg . depth ,
112+ path : pkg . path ,
113+ vulnerabilities,
114+ counts,
115+ }
69116 } catch ( error ) {
70117 // 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' }
118+ console . warn ( `[dep-analysis] OSV detail query failed for ${ pkg . name } @${ pkg . version } :` , error )
119+ return null
73120 }
74121}
75122
@@ -110,17 +157,24 @@ function getSeverityLevel(vuln: OsvVulnerability): OsvSeverityLevel {
110157
111158/**
112159 * Analyze entire dependency tree for vulnerabilities and deprecated packages.
160+ * Uses OSV batch API for efficient vulnerability discovery, then fetches
161+ * full details only for packages with known vulnerabilities.
113162 */
114163export const analyzeDependencyTree = defineCachedFunction (
115164 async ( name : string , version : string ) : Promise < VulnerabilityTreeResult > => {
116165 // Resolve all packages in the tree with depth tracking
117166 const resolved = await resolveDependencyTree ( name , version , { trackDepth : true } )
118167
119- // Convert to array for OSV querying
120- const packages = [ ...resolved . values ( ) ]
168+ // Convert to array with query info
169+ const packages : PackageQueryInfo [ ] = [ ...resolved . values ( ) ] . map ( pkg => ( {
170+ name : pkg . name ,
171+ version : pkg . version ,
172+ depth : pkg . depth ! ,
173+ path : pkg . path || [ ] ,
174+ } ) )
121175
122176 // Collect deprecated packages (no API call needed - already in packument data)
123- const deprecatedPackages : DeprecatedPackageInfo [ ] = packages
177+ const deprecatedPackages : DeprecatedPackageInfo [ ] = [ ... resolved . values ( ) ]
124178 . filter ( pkg => pkg . deprecated )
125179 . map ( pkg => ( {
126180 name : pkg . name ,
@@ -135,22 +189,27 @@ export const analyzeDependencyTree = defineCachedFunction(
135189 return depthOrder [ a . depth ] - depthOrder [ b . depth ]
136190 } )
137191
138- // Query OSV for all packages in parallel batches
139- const vulnerablePackages : PackageVulnerabilityInfo [ ] = [ ]
140- let failedQueries = 0
141- const batchSize = 10
192+ // Step 1: Use batch API to find which packages have vulnerabilities
193+ // This is much faster than individual queries - one request for all packages
194+ const { vulnerableIndices, failed : batchFailed } = await queryOsvBatch ( packages )
195+
196+ let vulnerablePackages : PackageVulnerabilityInfo [ ] = [ ]
197+ let failedQueries = batchFailed ? packages . length : 0
198+
199+ if ( ! batchFailed && vulnerableIndices . length > 0 ) {
200+ // Step 2: Fetch full vulnerability details only for packages with vulns
201+ // This is typically a small fraction of total packages
202+ const vulnerablePackageInfos = vulnerableIndices . map ( i => packages [ i ] ! )
142203
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 || [ ] ) ) ,
204+ const detailResults = await Promise . all (
205+ vulnerablePackageInfos . map ( pkg => queryOsvDetails ( pkg ) ) ,
147206 )
148207
149- for ( const result of results ) {
150- if ( result . status === 'error' ) {
208+ for ( const result of detailResults ) {
209+ if ( result ) {
210+ vulnerablePackages . push ( result )
211+ } else {
151212 failedQueries ++
152- } else if ( result . data ) {
153- vulnerablePackages . push ( result . data )
154213 }
155214 }
156215 }
@@ -175,11 +234,11 @@ export const analyzeDependencyTree = defineCachedFunction(
175234 totalCounts . low += pkg . counts . low
176235 }
177236
178- // Log critical failures (>50% of queries failed)
179- if ( failedQueries > 0 && failedQueries > packages . length / 2 ) {
237+ // Log if batch query failed entirely
238+ if ( batchFailed ) {
180239 // oxlint-disable-next-line no-console -- critical error logging
181240 console . error (
182- `[dep-analysis] Critical: ${ failedQueries } / ${ packages . length } OSV queries failed for ${ name } @${ version } ` ,
241+ `[dep-analysis] Critical: OSV batch query failed for ${ name } @${ version } ( ${ packages . length } packages) ` ,
183242 )
184243 }
185244
@@ -197,6 +256,6 @@ export const analyzeDependencyTree = defineCachedFunction(
197256 maxAge : 60 * 60 ,
198257 swr : true ,
199258 name : 'dependency-analysis' ,
200- getKey : ( name : string , version : string ) => `v1 :${ name } @${ version } ` ,
259+ getKey : ( name : string , version : string ) => `v2 :${ name } @${ version } ` ,
201260 } ,
202261)
0 commit comments