[READ-ONLY] a fast, modern browser for the npm registry
at main 361 lines 13 kB view raw
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)