Skip to content

Commit cc2255d

Browse files
committed
perf: use osv batch api for dep analysis
1 parent 20c1464 commit cc2255d

3 files changed

Lines changed: 334 additions & 142 deletions

File tree

server/utils/dependency-analysis.ts

Lines changed: 93 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type {
22
OsvQueryResponse,
3+
OsvBatchResponse,
34
OsvVulnerability,
45
OsvSeverityLevel,
56
VulnerabilitySummary,
@@ -10,29 +11,68 @@ import type {
1011
} from '#shared/types/dependency-analysis'
1112
import { 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
*/
114163
export 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
)

shared/types/dependency-analysis.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,30 @@ export interface OsvQueryResponse {
6464
next_page_token?: string
6565
}
6666

67+
/**
68+
* Single result from OSV batch query (minimal info - just ID and modified)
69+
*/
70+
export interface OsvBatchVulnRef {
71+
id: string
72+
modified: string
73+
}
74+
75+
/**
76+
* Single result in OSV batch response
77+
*/
78+
export interface OsvBatchResult {
79+
vulns?: OsvBatchVulnRef[]
80+
next_page_token?: string
81+
}
82+
83+
/**
84+
* OSV batch query response
85+
* @see https://google.github.io/osv.dev/post-v1-querybatch/
86+
*/
87+
export interface OsvBatchResponse {
88+
results: OsvBatchResult[]
89+
}
90+
6791
/**
6892
* Simplified vulnerability info for display
6993
*/

0 commit comments

Comments
 (0)