forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import type {
2 OsvQueryResponse,
3 OsvBatchResponse,
4 OsvVulnerability,
5 OsvSeverityLevel,
6 VulnerabilitySummary,
7 DependencyDepth,
8 PackageVulnerabilityInfo,
9 VulnerabilityTreeResult,
10 DeprecatedPackageInfo,
11 OsvAffected,
12 OsvRange,
13} from '#shared/types/dependency-analysis'
14import { mapWithConcurrency } from '#shared/utils/async'
15import { resolveDependencyTree } from './dependency-resolver'
16import * as semver from 'semver'
17
18/** Maximum concurrent requests for fetching vulnerability details */
19const OSV_DETAIL_CONCURRENCY = 25
20
21/** Package info needed for OSV queries */
22interface PackageQueryInfo {
23 name: string
24 version: string
25 depth: DependencyDepth
26 path: string[]
27}
28
29/**
30 * Query OSV batch API to find which packages have vulnerabilities.
31 * Returns indices of packages that have vulnerabilities (for follow-up detailed queries).
32 * @see https://google.github.io/osv.dev/post-v1-querybatch/
33 */
34async function queryOsvBatch(
35 packages: PackageQueryInfo[],
36): Promise<{ vulnerableIndices: number[]; failed: boolean }> {
37 if (packages.length === 0) return { vulnerableIndices: [], failed: false }
38
39 try {
40 const response = await $fetch<OsvBatchResponse>('https://api.osv.dev/v1/querybatch', {
41 method: 'POST',
42 body: {
43 queries: packages.map(pkg => ({
44 package: { name: pkg.name, ecosystem: 'npm' },
45 version: pkg.version,
46 })),
47 },
48 })
49
50 // Find indices of packages that have vulnerabilities
51 const vulnerableIndices: number[] = []
52 for (let i = 0; i < response.results.length; i++) {
53 const result = response.results[i]
54 if (result?.vulns && result.vulns.length > 0) {
55 vulnerableIndices.push(i)
56 }
57 // Warn if pagination token present (>1000 vulns for single query or >3000 total)
58 // This is extremely unlikely for npm packages but log for visibility
59 if (result?.next_page_token) {
60 // oxlint-disable-next-line no-console -- warn about paginated results
61 console.warn(
62 `[dep-analysis] OSV batch result has pagination token for package index ${i} ` +
63 `(${packages[i]?.name}@${packages[i]?.version}) - some vulnerabilities may be missing`,
64 )
65 }
66 }
67
68 return { vulnerableIndices, failed: false }
69 } catch (error) {
70 // oxlint-disable-next-line no-console -- log OSV API failures for debugging
71 console.warn(`[dep-analysis] OSV batch query failed:`, error)
72 return { vulnerableIndices: [], failed: true }
73 }
74}
75
76/**
77 * Query OSV for full vulnerability details for a single package.
78 * Only called for packages known to have vulnerabilities.
79 */
80async function queryOsvDetails(pkg: PackageQueryInfo): Promise<PackageVulnerabilityInfo | null> {
81 try {
82 const response = await $fetch<OsvQueryResponse>('https://api.osv.dev/v1/query', {
83 method: 'POST',
84 body: {
85 package: { name: pkg.name, ecosystem: 'npm' },
86 version: pkg.version,
87 },
88 })
89
90 const vulns = response.vulns || []
91 if (vulns.length === 0) return null
92
93 const counts = { total: vulns.length, critical: 0, high: 0, moderate: 0, low: 0 }
94 const vulnerabilities: VulnerabilitySummary[] = []
95
96 const severityOrder: Record<OsvSeverityLevel, number> = {
97 critical: 0,
98 high: 1,
99 moderate: 2,
100 low: 3,
101 unknown: 4,
102 }
103
104 const sortedVulns = [...vulns].sort(
105 (a, b) => severityOrder[getSeverityLevel(a)] - severityOrder[getSeverityLevel(b)],
106 )
107
108 for (const vuln of sortedVulns) {
109 const severity = getSeverityLevel(vuln)
110 if (severity === 'critical') counts.critical++
111 else if (severity === 'high') counts.high++
112 else if (severity === 'moderate') counts.moderate++
113 else if (severity === 'low') counts.low++
114
115 vulnerabilities.push({
116 id: vuln.id,
117 summary: vuln.summary || 'No description available',
118 severity,
119 aliases: vuln.aliases || [],
120 url: getVulnerabilityUrl(vuln),
121 fixedIn: getFixedVersion(vuln.affected, pkg.name, pkg.version),
122 })
123 }
124
125 return {
126 name: pkg.name,
127 version: pkg.version,
128 depth: pkg.depth,
129 path: pkg.path,
130 vulnerabilities,
131 counts,
132 }
133 } catch (error) {
134 // oxlint-disable-next-line no-console -- log OSV API failures for debugging
135 console.warn(`[dep-analysis] OSV detail query failed for ${pkg.name}@${pkg.version}:`, error)
136 return null
137 }
138}
139
140function getVulnerabilityUrl(vuln: OsvVulnerability): string {
141 if (vuln.id.startsWith('GHSA-')) {
142 return `https://github.com/advisories/${vuln.id}`
143 }
144 const cveAlias = vuln.aliases?.find(a => a.startsWith('CVE-'))
145 if (cveAlias) {
146 return `https://nvd.nist.gov/vuln/detail/${cveAlias}`
147 }
148 return `https://osv.dev/vulnerability/${vuln.id}`
149}
150
151/**
152 * Parse OSV range events into introduced/fixed pairs.
153 * OSV events form a timeline: [introduced, fixed, introduced, fixed, ...]
154 * A single range can have multiple introduced/fixed pairs representing
155 * periods where the vulnerability was active, was fixed, and was reintroduced.
156 * @see https://ossf.github.io/osv-schema/#affectedrangesevents-fields
157 */
158function parseRangeIntervals(range: OsvRange): Array<{ introduced: string; fixed?: string }> {
159 const intervals: Array<{ introduced: string; fixed?: string }> = []
160 let currentIntroduced: string | undefined
161
162 for (const event of range.events) {
163 if (event.introduced !== undefined) {
164 // Start a new interval (close previous open one if any)
165 if (currentIntroduced !== undefined) {
166 intervals.push({ introduced: currentIntroduced })
167 }
168 currentIntroduced = event.introduced
169 } else if (event.fixed !== undefined && currentIntroduced !== undefined) {
170 intervals.push({ introduced: currentIntroduced, fixed: event.fixed })
171 currentIntroduced = undefined
172 }
173 }
174
175 // Handle trailing introduced with no fixed (still vulnerable)
176 if (currentIntroduced !== undefined) {
177 intervals.push({ introduced: currentIntroduced })
178 }
179
180 return intervals
181}
182
183/**
184 * Extract the fixed version for a specific package version from vulnerability data.
185 * Finds all intervals that contain the current version and returns the closest fix,
186 * preferring a nearby backport over a distant major-version bump.
187 * @see https://ossf.github.io/osv-schema/#affectedrangesevents-fields
188 */
189function getFixedVersion(
190 affected: OsvAffected[] | undefined,
191 packageName: string,
192 currentVersion: string,
193): string | undefined {
194 if (!affected) return undefined
195
196 // Find all affected entries for this specific package
197 const packageAffectedEntries = affected.filter(
198 a => a.package.ecosystem === 'npm' && a.package.name === packageName,
199 )
200
201 // Collect all matching fixed versions across all ranges
202 const matchingFixedVersions: string[] = []
203
204 for (const entry of packageAffectedEntries) {
205 if (!entry.ranges) continue
206
207 for (const range of entry.ranges) {
208 // Only handle SEMVER ranges (most common for npm)
209 if (range.type !== 'SEMVER') continue
210
211 const intervals = parseRangeIntervals(range)
212 for (const interval of intervals) {
213 const introVersion = interval.introduced === '0' ? '0.0.0' : interval.introduced
214 try {
215 const afterIntro = semver.gte(currentVersion, introVersion)
216 const beforeFixed = !interval.fixed || semver.lt(currentVersion, interval.fixed)
217 if (afterIntro && beforeFixed && interval.fixed) {
218 matchingFixedVersions.push(interval.fixed)
219 }
220 } catch {
221 continue
222 }
223 }
224 }
225 }
226
227 if (matchingFixedVersions.length === 0) return undefined
228 if (matchingFixedVersions.length === 1) return matchingFixedVersions[0]
229
230 // Return the lowest (closest) fixed version — the smallest bump from the current version
231 return matchingFixedVersions.sort(semver.compare)[0]
232}
233
234function getSeverityLevel(vuln: OsvVulnerability): OsvSeverityLevel {
235 const dbSeverity = vuln.database_specific?.severity?.toLowerCase()
236 if (dbSeverity) {
237 if (dbSeverity === 'critical') return 'critical'
238 if (dbSeverity === 'high') return 'high'
239 if (dbSeverity === 'moderate' || dbSeverity === 'medium') return 'moderate'
240 if (dbSeverity === 'low') return 'low'
241 }
242
243 const severityEntry = vuln.severity?.[0]
244 if (severityEntry?.score) {
245 const match = severityEntry.score.match(/(?:^|[/:])(\d+(?:\.\d+)?)$/)
246 if (match?.[1]) {
247 const score = parseFloat(match[1])
248 if (score >= 9.0) return 'critical'
249 if (score >= 7.0) return 'high'
250 if (score >= 4.0) return 'moderate'
251 if (score > 0) return 'low'
252 }
253 }
254
255 return 'unknown'
256}
257
258/**
259 * Analyze entire dependency tree for vulnerabilities and deprecated packages.
260 * Uses OSV batch API for efficient vulnerability discovery, then fetches
261 * full details only for packages with known vulnerabilities.
262 */
263export const analyzeDependencyTree = defineCachedFunction(
264 async (name: string, version: string): Promise<VulnerabilityTreeResult> => {
265 // Resolve all packages in the tree with depth tracking
266 const resolved = await resolveDependencyTree(name, version, { trackDepth: true })
267
268 // Convert to array with query info
269 const packages: PackageQueryInfo[] = Array.from(resolved.values(), pkg => ({
270 name: pkg.name,
271 version: pkg.version,
272 depth: pkg.depth!,
273 path: pkg.path || [],
274 }))
275
276 // Collect deprecated packages (no API call needed - already in packument data)
277 const deprecatedPackages: DeprecatedPackageInfo[] = [...resolved.values()]
278 .filter(pkg => pkg.deprecated)
279 .map(pkg => ({
280 name: pkg.name,
281 version: pkg.version,
282 depth: pkg.depth!,
283 path: pkg.path || [],
284 message: pkg.deprecated!,
285 }))
286 .sort((a, b) => {
287 // Sort by depth (root → direct → transitive)
288 const depthOrder: Record<DependencyDepth, number> = { root: 0, direct: 1, transitive: 2 }
289 return depthOrder[a.depth] - depthOrder[b.depth]
290 })
291
292 // Step 1: Use batch API to find which packages have vulnerabilities
293 // This is much faster than individual queries - one request for all packages
294 const { vulnerableIndices, failed: batchFailed } = await queryOsvBatch(packages)
295
296 let vulnerablePackages: PackageVulnerabilityInfo[] = []
297 let failedQueries = batchFailed ? packages.length : 0
298
299 if (!batchFailed && vulnerableIndices.length > 0) {
300 // Step 2: Fetch full vulnerability details only for packages with vulns
301 // This is typically a small fraction of total packages
302 const detailResults = await mapWithConcurrency(
303 vulnerableIndices,
304 i => queryOsvDetails(packages[i]!),
305 OSV_DETAIL_CONCURRENCY,
306 )
307
308 for (const result of detailResults) {
309 if (result) {
310 vulnerablePackages.push(result)
311 } else {
312 failedQueries++
313 }
314 }
315 }
316
317 // Sort by depth (root → direct → transitive), then by severity
318 const depthOrder: Record<DependencyDepth, number> = { root: 0, direct: 1, transitive: 2 }
319 vulnerablePackages.sort((a, b) => {
320 if (a.depth !== b.depth) return depthOrder[a.depth] - depthOrder[b.depth]
321 if (a.counts.critical !== b.counts.critical) return b.counts.critical - a.counts.critical
322 if (a.counts.high !== b.counts.high) return b.counts.high - a.counts.high
323 if (a.counts.moderate !== b.counts.moderate) return b.counts.moderate - a.counts.moderate
324 return b.counts.total - a.counts.total
325 })
326
327 // Aggregate total counts
328 const totalCounts = { total: 0, critical: 0, high: 0, moderate: 0, low: 0 }
329 for (const pkg of vulnerablePackages) {
330 totalCounts.total += pkg.counts.total
331 totalCounts.critical += pkg.counts.critical
332 totalCounts.high += pkg.counts.high
333 totalCounts.moderate += pkg.counts.moderate
334 totalCounts.low += pkg.counts.low
335 }
336
337 // Log if batch query failed entirely
338 if (batchFailed) {
339 // oxlint-disable-next-line no-console -- critical error logging
340 console.error(
341 `[dep-analysis] Critical: OSV batch query failed for ${name}@${version} (${packages.length} packages)`,
342 )
343 }
344
345 return {
346 package: name,
347 version,
348 vulnerablePackages,
349 deprecatedPackages,
350 totalPackages: packages.length,
351 failedQueries,
352 totalCounts,
353 }
354 },
355 {
356 maxAge: 60 * 60,
357 swr: true,
358 name: 'dependency-analysis',
359 getKey: (name: string, version: string) => `v2:${name}@${version}`,
360 },
361)