From aff3d5accfd19cefdd07ec9cd6a08d3f6da42657 Mon Sep 17 00:00:00 2001 From: Jordan Harband Date: Mon, 17 Aug 2026 15:56:20 -0700 Subject: [PATCH 1/5] refactor: extract the OSV client and add per-source scan status Extract the OSV API client out of dependency-analysis into a shared server/utils/osv.ts, and additively extend the vulnerabilities API response with per-source metadata: - VulnerabilityTreeResult gains sourceStatus ({ osv: 'ok' | 'partial' | 'failed' }) so consumers can distinguish "no known vulnerabilities" from "could not check" - VulnerabilitySummary entries are tagged with the sources that reported them - cache keys bumped (route vulnerabilities:v1 -> v2, function v2 -> v3) for the new response shape - results degraded by a transient source failure are only served from the dependency-analysis cache for five minutes instead of an hour, and the route response cache now matches its 300s ISR rule, so an OSV blip doesn't strip findings from caches long after recovery The new metadata is additive; consuming it in the UI is a separate change. --- .../registry/vulnerabilities/[...pkg].get.ts | 9 +- server/utils/dependency-analysis.ts | 268 ++--------------- server/utils/osv.ts | 273 ++++++++++++++++++ shared/types/dependency-analysis.ts | 17 ++ shared/utils/security-sources.ts | 12 + test/e2e/vulnerabilities.spec.ts | 1 + .../server/utils/dependency-analysis.spec.ts | 105 +++++++ 7 files changed, 436 insertions(+), 249 deletions(-) create mode 100644 server/utils/osv.ts create mode 100644 shared/utils/security-sources.ts diff --git a/server/api/registry/vulnerabilities/[...pkg].get.ts b/server/api/registry/vulnerabilities/[...pkg].get.ts index 1dac0ab364..4b50c75ab2 100644 --- a/server/api/registry/vulnerabilities/[...pkg].get.ts +++ b/server/api/registry/vulnerabilities/[...pkg].get.ts @@ -1,6 +1,6 @@ import * as v from 'valibot' import { PackageRouteParamsSchema } from '#shared/schemas/package' -import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants' +import { CACHE_MAX_AGE_FIVE_MINUTES } from '#shared/utils/constants' /** * GET /api/registry/vulnerabilities/:name or /api/registry/vulnerabilities/:name/v/:version @@ -41,11 +41,14 @@ export default defineCachedEventHandler( } }, { - maxAge: CACHE_MAX_AGE_ONE_HOUR, + // Short response cache (matching the /api/** ISR rule) so freshness is + // governed by the analyzeDependencyTree function cache, which keeps + // complete results for an hour but revalidates degraded ones quickly + maxAge: CACHE_MAX_AGE_FIVE_MINUTES, swr: true, getKey: event => { const pkg = getRouterParam(event, 'pkg') ?? '' - return `vulnerabilities:v1:${pkg.replace(/\/+$/, '').trim()}` + return `vulnerabilities:v2:${pkg.replace(/\/+$/, '').trim()}` }, }, ) diff --git a/server/utils/dependency-analysis.ts b/server/utils/dependency-analysis.ts index 0b42ee636e..30c0277c9f 100644 --- a/server/utils/dependency-analysis.ts +++ b/server/utils/dependency-analysis.ts @@ -1,260 +1,19 @@ import type { - OsvQueryResponse, - OsvBatchResponse, - OsvVulnerability, - OsvSeverityLevel, - VulnerabilitySummary, DependencyDepth, PackageVulnerabilityInfo, + SecuritySourceStatus, VulnerabilityTreeResult, DeprecatedPackageInfo, - OsvAffected, - OsvRange, } from '#shared/types/dependency-analysis' import { mapWithConcurrency } from '#shared/utils/async' +import { hasTransientSourceFailure } from '#shared/utils/security-sources' +import { CACHE_MAX_AGE_FIVE_MINUTES } from '#shared/utils/constants' import { resolveDependencyTree } from './dependency-resolver' -import { compare, isGreaterOrEqual, isLess } from 'verkit' +import { queryOsvBatch, queryOsvDetails, type PackageQueryInfo } from './osv' /** Maximum concurrent requests for fetching vulnerability details */ const OSV_DETAIL_CONCURRENCY = 25 -/** Package info needed for OSV queries */ -interface PackageQueryInfo { - name: string - version: string - depth: DependencyDepth - path: string[] -} - -/** - * Query OSV batch API to find which packages have vulnerabilities. - * Returns indices of packages that have vulnerabilities (for follow-up detailed queries). - * @see https://google.github.io/osv.dev/post-v1-querybatch/ - */ -async function queryOsvBatch( - packages: PackageQueryInfo[], -): Promise<{ vulnerableIndices: number[]; failed: boolean }> { - if (packages.length === 0) return { vulnerableIndices: [], failed: false } - - try { - const response = await $fetch('https://api.osv.dev/v1/querybatch', { - method: 'POST', - body: { - queries: packages.map(pkg => ({ - package: { name: pkg.name, ecosystem: 'npm' }, - version: pkg.version, - })), - }, - }) - - // Find indices of packages that have vulnerabilities - const vulnerableIndices: number[] = [] - for (let i = 0; i < response.results.length; i++) { - const result = response.results[i] - if (result?.vulns && result.vulns.length > 0) { - vulnerableIndices.push(i) - } - // Warn if pagination token present (>1000 vulns for single query or >3000 total) - // This is extremely unlikely for npm packages but log for visibility - if (result?.next_page_token) { - // oxlint-disable-next-line no-console -- warn about paginated results - console.warn( - `[dep-analysis] OSV batch result has pagination token for package index ${i} ` + - `(${packages[i]?.name}@${packages[i]?.version}) - some vulnerabilities may be missing`, - ) - } - } - - return { vulnerableIndices, failed: false } - } catch (error) { - // oxlint-disable-next-line no-console -- log OSV API failures for debugging - console.warn(`[dep-analysis] OSV batch query failed:`, error) - return { vulnerableIndices: [], failed: true } - } -} - -/** - * Query OSV for full vulnerability details for a single package. - * Only called for packages known to have vulnerabilities. - */ -async function queryOsvDetails(pkg: PackageQueryInfo): Promise { - try { - const response = await $fetch('https://api.osv.dev/v1/query', { - method: 'POST', - body: { - package: { name: pkg.name, ecosystem: 'npm' }, - version: pkg.version, - }, - }) - - const vulns = response.vulns || [] - if (vulns.length === 0) return null - - const counts = { total: vulns.length, critical: 0, high: 0, moderate: 0, low: 0 } - const vulnerabilities: VulnerabilitySummary[] = [] - - const severityOrder: Record = { - critical: 0, - high: 1, - moderate: 2, - low: 3, - unknown: 4, - } - - const sortedVulns = [...vulns].sort( - (a, b) => severityOrder[getSeverityLevel(a)] - severityOrder[getSeverityLevel(b)], - ) - - for (const vuln of sortedVulns) { - const severity = getSeverityLevel(vuln) - if (severity === 'critical') counts.critical++ - else if (severity === 'high') counts.high++ - else if (severity === 'moderate') counts.moderate++ - else if (severity === 'low') counts.low++ - - vulnerabilities.push({ - id: vuln.id, - summary: vuln.summary || 'No description available', - severity, - aliases: vuln.aliases || [], - url: getVulnerabilityUrl(vuln), - fixedIn: getFixedVersion(vuln.affected, pkg.name, pkg.version), - }) - } - - return { - name: pkg.name, - version: pkg.version, - depth: pkg.depth, - path: pkg.path, - vulnerabilities, - counts, - } - } catch (error) { - // oxlint-disable-next-line no-console -- log OSV API failures for debugging - console.warn(`[dep-analysis] OSV detail query failed for ${pkg.name}@${pkg.version}:`, error) - return null - } -} - -function getVulnerabilityUrl(vuln: OsvVulnerability): string { - if (vuln.id.startsWith('GHSA-')) { - return `https://github.com/advisories/${vuln.id}` - } - const cveAlias = vuln.aliases?.find(a => a.startsWith('CVE-')) - if (cveAlias) { - return `https://nvd.nist.gov/vuln/detail/${cveAlias}` - } - return `https://osv.dev/vulnerability/${vuln.id}` -} - -/** - * Parse OSV range events into introduced/fixed pairs. - * OSV events form a timeline: [introduced, fixed, introduced, fixed, ...] - * A single range can have multiple introduced/fixed pairs representing - * periods where the vulnerability was active, was fixed, and was reintroduced. - * @see https://ossf.github.io/osv-schema/#affectedrangesevents-fields - */ -function parseRangeIntervals(range: OsvRange): Array<{ introduced: string; fixed?: string }> { - const intervals: Array<{ introduced: string; fixed?: string }> = [] - let currentIntroduced: string | undefined - - for (const event of range.events) { - if (event.introduced !== undefined) { - // Start a new interval (close previous open one if any) - if (currentIntroduced !== undefined) { - intervals.push({ introduced: currentIntroduced }) - } - currentIntroduced = event.introduced - } else if (event.fixed !== undefined && currentIntroduced !== undefined) { - intervals.push({ introduced: currentIntroduced, fixed: event.fixed }) - currentIntroduced = undefined - } - } - - // Handle trailing introduced with no fixed (still vulnerable) - if (currentIntroduced !== undefined) { - intervals.push({ introduced: currentIntroduced }) - } - - return intervals -} - -/** - * Extract the fixed version for a specific package version from vulnerability data. - * Finds all intervals that contain the current version and returns the closest fix, - * preferring a nearby backport over a distant major-version bump. - * @see https://ossf.github.io/osv-schema/#affectedrangesevents-fields - */ -function getFixedVersion( - affected: OsvAffected[] | undefined, - packageName: string, - currentVersion: string, -): string | undefined { - if (!affected) return undefined - - // Find all affected entries for this specific package - const packageAffectedEntries = affected.filter( - a => a.package.ecosystem === 'npm' && a.package.name === packageName, - ) - - // Collect all matching fixed versions across all ranges - const matchingFixedVersions: string[] = [] - - for (const entry of packageAffectedEntries) { - if (!entry.ranges) continue - - for (const range of entry.ranges) { - // Only handle SEMVER ranges (most common for npm) - if (range.type !== 'SEMVER') continue - - const intervals = parseRangeIntervals(range) - for (const interval of intervals) { - const introVersion = interval.introduced === '0' ? '0.0.0' : interval.introduced - try { - const afterIntro = isGreaterOrEqual(currentVersion, introVersion) - const beforeFixed = !interval.fixed || isLess(currentVersion, interval.fixed) - if (afterIntro && beforeFixed && interval.fixed) { - matchingFixedVersions.push(interval.fixed) - } - } catch { - continue - } - } - } - } - - if (matchingFixedVersions.length === 0) return undefined - if (matchingFixedVersions.length === 1) return matchingFixedVersions[0] - - // Return the lowest (closest) fixed version — the smallest bump from the current version - return matchingFixedVersions.sort(compare)[0] -} - -function getSeverityLevel(vuln: OsvVulnerability): OsvSeverityLevel { - const dbSeverity = vuln.database_specific?.severity?.toLowerCase() - if (dbSeverity) { - if (dbSeverity === 'critical') return 'critical' - if (dbSeverity === 'high') return 'high' - if (dbSeverity === 'moderate' || dbSeverity === 'medium') return 'moderate' - if (dbSeverity === 'low') return 'low' - } - - const severityEntry = vuln.severity?.[0] - if (severityEntry?.score) { - const match = severityEntry.score.match(/(?:^|[/:])(\d+(?:\.\d+)?)$/) - if (match?.[1]) { - const score = parseFloat(match[1]) - if (score >= 9.0) return 'critical' - if (score >= 7.0) return 'high' - if (score >= 4.0) return 'moderate' - if (score > 0) return 'low' - } - } - - return 'unknown' -} - /** * Analyze entire dependency tree for vulnerabilities and deprecated packages. * Uses OSV batch API for efficient vulnerability discovery, then fetches @@ -342,6 +101,12 @@ export const analyzeDependencyTree = defineCachedFunction( ) } + const osvStatus: SecuritySourceStatus = batchFailed + ? 'failed' + : failedQueries > 0 + ? 'partial' + : 'ok' + return { package: name, version, @@ -350,12 +115,23 @@ export const analyzeDependencyTree = defineCachedFunction( totalPackages: packages.length, failedQueries, totalCounts, + sourceStatus: { osv: osvStatus }, } }, { maxAge: 60 * 60, swr: true, name: 'dependency-analysis', - getKey: (name: string, version: string) => `v2:${name}@${version}`, + getKey: (name: string, version: string) => `v3:${name}@${version}`, + // Results degraded by a transient source failure (e.g. an OSV outage) + // are only served from cache briefly, so a blip doesn't strip findings + // from the cache for a whole hour after recovery + validate: entry => { + // a custom validate replaces nitro's default entry.value !== undefined guard + const result = entry.value + if (!result) return false + if (!hasTransientSourceFailure(result.sourceStatus)) return true + return Date.now() - (entry.mtime ?? 0) < CACHE_MAX_AGE_FIVE_MINUTES * 1000 + }, }, ) diff --git a/server/utils/osv.ts b/server/utils/osv.ts new file mode 100644 index 0000000000..5ee26dfa89 --- /dev/null +++ b/server/utils/osv.ts @@ -0,0 +1,273 @@ +import type { + OsvQueryResponse, + OsvBatchResponse, + OsvVulnerability, + OsvSeverityLevel, + VulnerabilitySummary, + DependencyDepth, + PackageVulnerabilityInfo, + OsvAffected, + OsvRange, +} from '#shared/types/dependency-analysis' +import { compare, isGreaterOrEqual, isLess } from 'verkit' + +const OSV_QUERY_API = 'https://api.osv.dev/v1/query' +const OSV_QUERY_BATCH_API = 'https://api.osv.dev/v1/querybatch' + +// a stalled OSV connection would otherwise hang the whole security scan until +// the platform request limit fires; degrade to the failed-source path instead +const OSV_FETCH_TIMEOUT_MS = 10_000 + +/** Package info needed for OSV queries */ +export interface PackageQueryInfo { + name: string + version: string + depth: DependencyDepth + path: string[] +} + +/** + * Query OSV batch API to find which packages have vulnerabilities. + * Returns indices of packages that have vulnerabilities (for follow-up detailed queries). + * @see https://google.github.io/osv.dev/post-v1-querybatch/ + */ +export async function queryOsvBatch( + packages: PackageQueryInfo[], +): Promise<{ vulnerableIndices: number[]; failed: boolean }> { + if (packages.length === 0) return { vulnerableIndices: [], failed: false } + + try { + const response = await $fetch(OSV_QUERY_BATCH_API, { + method: 'POST', + timeout: OSV_FETCH_TIMEOUT_MS, + body: { + queries: packages.map(pkg => ({ + package: { name: pkg.name, ecosystem: 'npm' }, + version: pkg.version, + })), + }, + }) + + // Find indices of packages that have vulnerabilities + const vulnerableIndices: number[] = [] + for (let i = 0; i < response.results.length; i++) { + const result = response.results[i] + if (result?.vulns && result.vulns.length > 0) { + vulnerableIndices.push(i) + } + // Warn if pagination token present (>1000 vulns for single query or >3000 total) + // This is extremely unlikely for npm packages but log for visibility + if (result?.next_page_token) { + // oxlint-disable-next-line no-console -- warn about paginated results + console.warn( + `[dep-analysis] OSV batch result has pagination token for package index ${i} ` + + `(${packages[i]?.name}@${packages[i]?.version}) - some vulnerabilities may be missing`, + ) + } + } + + return { vulnerableIndices, failed: false } + } catch (error) { + // oxlint-disable-next-line no-console -- log OSV API failures for debugging + console.warn(`[dep-analysis] OSV batch query failed:`, error) + return { vulnerableIndices: [], failed: true } + } +} + +/** + * Query OSV for full vulnerability details for a single package. + * Only called for packages known to have vulnerabilities. + */ +export async function queryOsvDetails( + pkg: PackageQueryInfo, +): Promise { + try { + const response = await $fetch(OSV_QUERY_API, { + method: 'POST', + timeout: OSV_FETCH_TIMEOUT_MS, + body: { + package: { name: pkg.name, ecosystem: 'npm' }, + version: pkg.version, + }, + }) + + const vulns = response.vulns || [] + if (vulns.length === 0) return null + + const counts = { total: vulns.length, critical: 0, high: 0, moderate: 0, low: 0 } + const vulnerabilities: VulnerabilitySummary[] = [] + + const severityOrder: Record = { + critical: 0, + high: 1, + moderate: 2, + low: 3, + unknown: 4, + } + + const sortedVulns = [...vulns].sort( + (a, b) => severityOrder[getSeverityLevel(a)] - severityOrder[getSeverityLevel(b)], + ) + + for (const vuln of sortedVulns) { + const severity = getSeverityLevel(vuln) + if (severity === 'critical') counts.critical++ + else if (severity === 'high') counts.high++ + else if (severity === 'moderate') counts.moderate++ + else if (severity === 'low') counts.low++ + + vulnerabilities.push({ + id: vuln.id, + summary: vuln.summary || 'No description available', + severity, + aliases: vuln.aliases || [], + url: getVulnerabilityUrl(vuln), + fixedIn: getFixedVersion(vuln.affected, pkg.name, pkg.version), + sources: ['osv'], + }) + } + + return { + name: pkg.name, + version: pkg.version, + depth: pkg.depth, + path: pkg.path, + vulnerabilities, + counts, + } + } catch (error) { + // oxlint-disable-next-line no-console -- log OSV API failures for debugging + console.warn(`[dep-analysis] OSV detail query failed for ${pkg.name}@${pkg.version}:`, error) + return null + } +} + +function getVulnerabilityUrl(vuln: OsvVulnerability): string { + if (vuln.id.startsWith('GHSA-')) { + return `https://github.com/advisories/${vuln.id}` + } + const cveAlias = vuln.aliases?.find(a => a.startsWith('CVE-')) + if (cveAlias) { + return `https://nvd.nist.gov/vuln/detail/${cveAlias}` + } + return `https://osv.dev/vulnerability/${vuln.id}` +} + +/** + * Parse OSV range events into introduced/fixed pairs. + * OSV events form a timeline: [introduced, fixed, introduced, fixed, ...] + * A single range can have multiple introduced/fixed pairs representing + * periods where the vulnerability was active, was fixed, and was reintroduced. + * @see https://ossf.github.io/osv-schema/#affectedrangesevents-fields + */ +function parseRangeIntervals(range: OsvRange): Array<{ introduced: string; fixed?: string }> { + const intervals: Array<{ introduced: string; fixed?: string }> = [] + let currentIntroduced: string | undefined + + for (const event of range.events) { + if (event.introduced !== undefined) { + // Start a new interval (close previous open one if any) + if (currentIntroduced !== undefined) { + intervals.push({ introduced: currentIntroduced }) + } + currentIntroduced = event.introduced + } else if (event.fixed !== undefined && currentIntroduced !== undefined) { + intervals.push({ introduced: currentIntroduced, fixed: event.fixed }) + currentIntroduced = undefined + } + } + + // Handle trailing introduced with no fixed (still vulnerable) + if (currentIntroduced !== undefined) { + intervals.push({ introduced: currentIntroduced }) + } + + return intervals +} + +/** + * OSV SEMVER events sometimes carry shorthand versions (e.g. introduced + * "13.0" or "0"); pad the missing parts so semver comparisons don't reject + * the whole interval. + */ +function coerceSemver(version: string): string { + const parts = version.split('.') + while (parts.length < 3) parts.push('0') + return parts.join('.') +} + +/** + * Extract the fixed version for a specific package version from vulnerability data. + * Finds all intervals that contain the current version and returns the closest fix, + * preferring a nearby backport over a distant major-version bump. + * @see https://ossf.github.io/osv-schema/#affectedrangesevents-fields + */ +function getFixedVersion( + affected: OsvAffected[] | undefined, + packageName: string, + currentVersion: string, +): string | undefined { + if (!affected) return undefined + + // Find all affected entries for this specific package + const packageAffectedEntries = affected.filter( + a => a.package.ecosystem === 'npm' && a.package.name === packageName, + ) + + // Collect all matching fixed versions across all ranges + const matchingFixedVersions: string[] = [] + + for (const entry of packageAffectedEntries) { + if (!entry.ranges) continue + + for (const range of entry.ranges) { + // Only handle SEMVER ranges (most common for npm) + if (range.type !== 'SEMVER') continue + + const intervals = parseRangeIntervals(range) + for (const interval of intervals) { + const introVersion = coerceSemver(interval.introduced) + const fixedVersion = interval.fixed ? coerceSemver(interval.fixed) : undefined + try { + const afterIntro = isGreaterOrEqual(currentVersion, introVersion) + const beforeFixed = !fixedVersion || isLess(currentVersion, fixedVersion) + if (afterIntro && beforeFixed && fixedVersion) { + matchingFixedVersions.push(fixedVersion) + } + } catch { + continue + } + } + } + } + + if (matchingFixedVersions.length === 0) return undefined + if (matchingFixedVersions.length === 1) return matchingFixedVersions[0] + + // Return the lowest (closest) fixed version — the smallest bump from the current version + return matchingFixedVersions.sort(compare)[0] +} + +function getSeverityLevel(vuln: OsvVulnerability): OsvSeverityLevel { + const dbSeverity = vuln.database_specific?.severity?.toLowerCase() + if (dbSeverity) { + if (dbSeverity === 'critical') return 'critical' + if (dbSeverity === 'high') return 'high' + if (dbSeverity === 'moderate' || dbSeverity === 'medium') return 'moderate' + if (dbSeverity === 'low') return 'low' + } + + const severityEntry = vuln.severity?.[0] + if (severityEntry?.score) { + const match = severityEntry.score.match(/(?:^|[/:])(\d+(?:\.\d+)?)$/) + if (match?.[1]) { + const score = parseFloat(match[1]) + if (score >= 9.0) return 'critical' + if (score >= 7.0) return 'high' + if (score >= 4.0) return 'moderate' + if (score > 0) return 'low' + } + } + + return 'unknown' +} diff --git a/shared/types/dependency-analysis.ts b/shared/types/dependency-analysis.ts index a733c302e5..6d47d507e4 100644 --- a/shared/types/dependency-analysis.ts +++ b/shared/types/dependency-analysis.ts @@ -5,6 +5,19 @@ * @see https://google.github.io/osv.dev/api/ */ +/** + * Identifier for a security data source that can report vulnerabilities + */ +export type SecuritySourceId = 'osv' + +/** + * Fetch status for a single security data source: + * - `ok`: all queries succeeded + * - `partial`: some queries failed; results may be incomplete + * - `failed`: the source could not be queried at all + */ +export type SecuritySourceStatus = 'ok' | 'partial' | 'failed' + /** * Severity levels in priority order (highest first) */ @@ -131,6 +144,8 @@ export interface VulnerabilitySummary { url: string /** Version that fixes this vulnerability (if known) */ fixedIn?: string + /** Security data sources that reported this vulnerability */ + sources: SecuritySourceId[] } /** @@ -196,6 +211,8 @@ export interface VulnerabilityTreeResult { totalPackages: number /** Number of packages that could not be checked (OSV query failed) */ failedQueries: number + /** Per-source fetch status for the security data sources that were queried */ + sourceStatus: Record /** Aggregated counts across all packages */ totalCounts: { total: number diff --git a/shared/utils/security-sources.ts b/shared/utils/security-sources.ts new file mode 100644 index 0000000000..6c7664cf15 --- /dev/null +++ b/shared/utils/security-sources.ts @@ -0,0 +1,12 @@ +import type { SecuritySourceId, SecuritySourceStatus } from '../types/dependency-analysis' + +/** + * Whether any source failed for a (possibly transient) reason, e.g. an + * outage. Used to cache such results for a much shorter time than complete + * ones, so a blip doesn't strip findings from caches for an hour. + */ +export function hasTransientSourceFailure( + sourceStatus: Partial>, +): boolean { + return Object.values(sourceStatus).some(status => status === 'failed') +} diff --git a/test/e2e/vulnerabilities.spec.ts b/test/e2e/vulnerabilities.spec.ts index f39172b938..6bf1beac0d 100644 --- a/test/e2e/vulnerabilities.spec.ts +++ b/test/e2e/vulnerabilities.spec.ts @@ -24,6 +24,7 @@ test.describe('vulnerabilities API', () => { expect(body).toHaveProperty('package', 'vue') expect(body).toHaveProperty('version') expect(body).toHaveProperty('totalCounts') + expect(body).toHaveProperty('sourceStatus.osv') }) test('scoped package vulnerabilities with URL encoding', async ({ page, baseURL }) => { diff --git a/test/unit/server/utils/dependency-analysis.spec.ts b/test/unit/server/utils/dependency-analysis.spec.ts index 9fe2b17850..429a8c1a10 100644 --- a/test/unit/server/utils/dependency-analysis.spec.ts +++ b/test/unit/server/utils/dependency-analysis.spec.ts @@ -74,6 +74,7 @@ describe('dependency-analysis', () => { expect(result.totalPackages).toBe(1) expect(result.failedQueries).toBe(0) expect(result.totalCounts).toEqual({ total: 0, critical: 0, high: 0, moderate: 0, low: 0 }) + expect(result.sourceStatus).toEqual({ osv: 'ok' }) }) it('tracks failed queries when OSV batch API fails', async () => { @@ -117,6 +118,42 @@ describe('dependency-analysis', () => { // When batch fails, all packages are counted as failed expect(result.failedQueries).toBe(2) expect(result.totalPackages).toBe(2) + expect(result.sourceStatus).toEqual({ osv: 'failed' }) + }) + + it('reports partial source status when some detail queries fail', async () => { + // Suppress expected console output from error path + vi.spyOn(console, 'warn').mockImplementation(() => {}) + + const mockResolved = new Map([ + [ + 'test-pkg@1.0.0', + { + name: 'test-pkg', + version: '1.0.0', + size: 1000, + optional: false, + depth: 'root' as const, + path: ['test-pkg@1.0.0'], + tarballUrl: 'https://example.com/test-pkg-1.0.0.tgz', + }, + ], + ]) + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) + + // Batch succeeds and reports a vulnerable package, but the detail query fails + $fetchMock.mockImplementation(async (url: string) => { + if (url === 'https://api.osv.dev/v1/querybatch') { + return { results: [{ vulns: [{ id: 'GHSA-test', modified: '2024-01-01' }] }] } + } + throw new Error('OSV detail query failed') + }) + + const result = await analyzeDependencyTree('test-pkg', '1.0.0') + + expect(result.vulnerablePackages).toHaveLength(0) + expect(result.failedQueries).toBe(1) + expect(result.sourceStatus).toEqual({ osv: 'partial' }) }) it('correctly counts vulnerabilities by severity', async () => { @@ -166,6 +203,11 @@ describe('dependency-analysis', () => { expect(result.vulnerablePackages).toHaveLength(1) expect(result.totalCounts).toEqual({ total: 4, critical: 1, high: 1, moderate: 1, low: 1 }) + expect(result.sourceStatus).toEqual({ osv: 'ok' }) + // Every vulnerability found via OSV is tagged with its source + for (const vuln of result.vulnerablePackages[0]!.vulnerabilities) { + expect(vuln.sources).toEqual(['osv']) + } const pkg = result.vulnerablePackages[0] expect(pkg?.counts.critical).toBe(1) @@ -678,6 +720,69 @@ describe('dependency-analysis', () => { expect(result.vulnerablePackages[0]?.vulnerabilities[0]?.fixedIn).toBe('1.2.3') }) + it('handles shorthand semver in range events (e.g. introduced "13.0")', async () => { + const mockResolved = new Map([ + [ + 'next@14.2.5', + { + name: 'next', + version: '14.2.5', + size: 1000, + optional: false, + depth: 'root' as const, + path: ['next@14.2.5'], + tarballUrl: 'https://example.com/next-14.2.5.tgz', + }, + ], + ]) + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) + + // Mirrors GHSA-3h52-269p-cp9r: OSV publishes the backport range with a + // shorthand introduced version ("13.0"), which strict semver rejects + mockOsvApi( + [{ vulns: [{ id: 'GHSA-3h52-269p-cp9r', modified: '2025-01-01' }] }], + new Map([ + [ + 'next@14.2.5', + { + vulns: [ + { + id: 'GHSA-3h52-269p-cp9r', + summary: 'Information exposure in Next.js dev server', + database_specific: { severity: 'LOW' }, + affected: [ + { + package: { ecosystem: 'npm', name: 'next' }, + ranges: [ + { + type: 'SEMVER', + events: [{ introduced: '15.0.0' }, { fixed: '15.2.2' }], + }, + ], + }, + { + package: { ecosystem: 'npm', name: 'next' }, + ranges: [ + { + type: 'SEMVER', + events: [{ introduced: '13.0' }, { fixed: '14.2.30' }], + }, + ], + }, + ], + }, + ], + }, + ], + ]), + ) + + const result = await analyzeDependencyTree('next', '14.2.5') + + expect(result.vulnerablePackages).toHaveLength(1) + expect(result.vulnerablePackages[0]?.vulnerabilities[0]?.fixedIn).toBe('14.2.30') + }) + it('extracts correct fixedIn for prerelease versions (e.g., 16.0.0-beta.0)', async () => { const mockResolved = new Map([ [ From 60af5e60873ec52253ba4a8d7427340da5af09a9 Mon Sep 17 00:00:00 2001 From: Jordan Harband Date: Mon, 17 Aug 2026 15:56:47 -0700 Subject: [PATCH 2/5] fix: distinguish "could not check" from "no vulnerabilities" When a security data source fails, the UI reported a reassuring zero rather than admitting it could not check. Consume the per-source sourceStatus metadata to surface an unknown state instead: - the public vulnerabilities badge rendered a green "0" when the OSV query failed; it now renders a slate "unknown" badge (and the badge reuses the extracted OSV client via a new fetchOsvVulnerabilityCount that returns null, not 0, on failure) - the package page stats banner showed a check-marked 0 when every source failed; it now shows "-" - the compare view coerced a failed vulnerabilities fetch to a clean zero count; unknown results are now excluded from table and chart - the vulnerability tree now shows its "could not scan" state when a response carries no source data, instead of rendering nothing Adds allSecuritySourcesFailed to drive these display decisions. --- app/components/Package/VulnerabilityTree.vue | 11 +++++- app/composables/usePackageComparison.ts | 17 +++------ app/pages/package/[[org]]/[name].vue | 7 +++- .../api/registry/badge/[type]/[...pkg].get.ts | 25 ++++-------- server/utils/osv.ts | 26 +++++++++++++ shared/utils/security-sources.ts | 12 ++++++ test/unit/server/utils/osv.spec.ts | 38 +++++++++++++++++++ 7 files changed, 105 insertions(+), 31 deletions(-) create mode 100644 test/unit/server/utils/osv.spec.ts diff --git a/app/components/Package/VulnerabilityTree.vue b/app/components/Package/VulnerabilityTree.vue index bca8eb312d..1c7bd54573 100644 --- a/app/components/Package/VulnerabilityTree.vue +++ b/app/components/Package/VulnerabilityTree.vue @@ -22,6 +22,12 @@ const hasVulnerabilities = computed( () => vulnTree.value && vulnTree.value.vulnerablePackages.length > 0, ) +// A "successful" response where every source failed contains no vulnerability +// info - treat it like a scan failure rather than a clean result +const allSourcesFailed = computed( + () => !!vulnTree.value && allSecuritySourcesFailed(vulnTree.value.sourceStatus), +) + // Banner - amber for better light mode contrast const bannerColor = 'border-amber-600/40 bg-amber-500/10 text-amber-800 dark:text-amber-400' @@ -213,7 +219,10 @@ function getDepthStyle(depth: string | undefined) { -
+