Skip to content

Commit 0df32a3

Browse files
authored
perf: use osv batch api for dep analysis (#665)
1 parent 89c7e48 commit 0df32a3

File tree

7 files changed

+558
-227
lines changed

7 files changed

+558
-227
lines changed

app/composables/useNpmRegistry.ts

Lines changed: 33 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
PackageVersionInfo,
1010
} from '#shared/types'
1111
import type { ReleaseType } from 'semver'
12+
import { mapWithConcurrency } from '#shared/utils/async'
1213
import { maxSatisfying, prerelease, major, minor, diff, gt, compare } from 'semver'
1314
import { isExactVersion } from '~/utils/versions'
1415
import { extractInstallScriptsInfo } from '~/utils/install-scripts'
@@ -546,34 +547,28 @@ export function useOrgPackages(orgName: MaybeRefOrGetter<string>) {
546547

547548
// Fetch packuments and downloads in parallel
548549
const [packuments, downloads] = await Promise.all([
549-
// Fetch packuments in parallel (with concurrency limit)
550+
// Fetch packuments with concurrency limit
550551
(async () => {
551-
const concurrency = 10
552-
const results: MinimalPackument[] = []
553-
for (let i = 0; i < packageNames.length; i += concurrency) {
554-
const batch = packageNames.slice(i, i + concurrency)
555-
const batchResults = await Promise.all(
556-
batch.map(async name => {
557-
try {
558-
const encoded = encodePackageName(name)
559-
const { data: pkg } = await cachedFetch<MinimalPackument>(
560-
`${NPM_REGISTRY}/${encoded}`,
561-
{ signal },
562-
)
563-
return pkg
564-
} catch {
565-
return null
566-
}
567-
}),
568-
)
569-
for (const pkg of batchResults) {
570-
// Filter out any unpublished packages (missing dist-tags)
571-
if (pkg && pkg['dist-tags']) {
572-
results.push(pkg)
552+
const results = await mapWithConcurrency(
553+
packageNames,
554+
async name => {
555+
try {
556+
const encoded = encodePackageName(name)
557+
const { data: pkg } = await cachedFetch<MinimalPackument>(
558+
`${NPM_REGISTRY}/${encoded}`,
559+
{ signal },
560+
)
561+
return pkg
562+
} catch {
563+
return null
573564
}
574-
}
575-
}
576-
return results
565+
},
566+
10,
567+
)
568+
// Filter out any unpublished packages (missing dist-tags)
569+
return results.filter(
570+
(pkg): pkg is MinimalPackument => pkg !== null && !!pkg['dist-tags'],
571+
)
577572
})(),
578573
// Fetch downloads in bulk
579574
fetchBulkDownloads(packageNames, { signal }),
@@ -772,23 +767,20 @@ export function useOutdatedDependencies(
772767
return
773768
}
774769

775-
const results: Record<string, OutdatedDependencyInfo> = {}
776770
const entries = Object.entries(deps)
777-
const batchSize = 5
778-
779-
for (let i = 0; i < entries.length; i += batchSize) {
780-
const batch = entries.slice(i, i + batchSize)
781-
const batchResults = await Promise.all(
782-
batch.map(async ([name, constraint]) => {
783-
const info = await checkDependencyOutdated(cachedFetch, name, constraint)
784-
return [name, info] as const
785-
}),
786-
)
771+
const batchResults = await mapWithConcurrency(
772+
entries,
773+
async ([name, constraint]) => {
774+
const info = await checkDependencyOutdated(cachedFetch, name, constraint)
775+
return [name, info] as const
776+
},
777+
5,
778+
)
787779

788-
for (const [name, info] of batchResults) {
789-
if (info) {
790-
results[name] = info
791-
}
780+
const results: Record<string, OutdatedDependencyInfo> = {}
781+
for (const [name, info] of batchResults) {
782+
if (info) {
783+
results[name] = info
792784
}
793785
}
794786

server/utils/dependency-analysis.ts

Lines changed: 106 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,
@@ -8,31 +9,83 @@ import type {
89
VulnerabilityTreeResult,
910
DeprecatedPackageInfo,
1011
} from '#shared/types/dependency-analysis'
12+
import { mapWithConcurrency } from '#shared/utils/async'
1113
import { 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
*/
114176
export 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

Comments
 (0)