diff --git a/app/components/DependencyPathPopup.vue b/app/components/DependencyPathPopup.vue new file mode 100644 index 0000000000..1a478b4d81 --- /dev/null +++ b/app/components/DependencyPathPopup.vue @@ -0,0 +1,124 @@ + + + + + + + + {{ t('package.vulnerabilities.path') }} + + + + + + + └─ + + {{ pathItem }} + + ⚠ + + + + + diff --git a/app/components/PackageDependencies.vue b/app/components/PackageDependencies.vue index 3102a80b7d..3eb4cb86e7 100644 --- a/app/components/PackageDependencies.vue +++ b/app/components/PackageDependencies.vue @@ -1,6 +1,10 @@ - - - - - - - - - - - {{ - $t( - 'package.vulnerabilities.found', - { count: vulnData.counts.total }, - vulnData.counts.total, - ) - }} - - - - {{ summaryText }} - - - - - - - - - - - - - {{ vuln.id }} - - - {{ vuln.severity }} - - - - {{ vuln.summary }} - - - - {{ alias }} - - - - - - - - - - - - - diff --git a/app/components/PackageVulnerabilityTree.vue b/app/components/PackageVulnerabilityTree.vue new file mode 100644 index 0000000000..017420f405 --- /dev/null +++ b/app/components/PackageVulnerabilityTree.vue @@ -0,0 +1,243 @@ + + + + + + + + + + + + {{ + t( + 'package.vulnerabilities.tree_found', + { + vulns: vulnTree!.totalCounts.total, + packages: vulnTree!.vulnerablePackages.length, + total: vulnTree!.totalPackages, + }, + vulnTree!.totalCounts.total, + ) + }} + + + + {{ summaryText }} + + + + + + + + + + + + + + + {{ pkg.name }}@{{ pkg.version }} + + + + + {{ pkg.counts[s] }} {{ t(`package.vulnerabilities.severity.${s}`) }} + + + + + + + + {{ vuln.id }} + + {{ vuln.summary }} + + + {{ t('package.vulnerabilities.more', { count: pkg.vulnerabilities.length - 2 }) }} + + + + + + + {{ + t('package.vulnerabilities.show_all_packages', { + count: vulnTree!.vulnerablePackages.length, + }) + }} + + + + + + {{ t('package.vulnerabilities.packages_failed', vulnTree!.failedQueries) }} + + + + + + + + + + + {{ t('package.vulnerabilities.scanning_tree') }} + + + + + + + + + + + {{ t('package.vulnerabilities.no_known', { count: vulnTree?.totalPackages ?? 0 }) }} + + + + + + {{ t('package.vulnerabilities.packages_failed', vulnTree.failedQueries) }} + + + + + + + + + + + {{ t('package.vulnerabilities.scan_failed') }} + + + + + diff --git a/app/composables/useNpmRegistry.ts b/app/composables/useNpmRegistry.ts index b0053056cd..78e689f798 100644 --- a/app/composables/useNpmRegistry.ts +++ b/app/composables/useNpmRegistry.ts @@ -469,7 +469,7 @@ export interface OutdatedDependencyInfo { * Check if a version constraint explicitly includes a prerelease tag. * e.g., "^1.0.0-alpha" or ">=2.0.0-beta.1" include prereleases */ -function constraintIncludesPrerelease(constraint: string): boolean { +export function constraintIncludesPrerelease(constraint: string): boolean { return ( /-(alpha|beta|rc|next|canary|dev|preview|pre|experimental)/i.test(constraint) || /-\d/.test(constraint) @@ -479,7 +479,7 @@ function constraintIncludesPrerelease(constraint: string): boolean { /** * Check if a constraint is a non-semver value (git URL, file path, etc.) */ -function isNonSemverConstraint(constraint: string): boolean { +export function isNonSemverConstraint(constraint: string): boolean { return ( constraint.startsWith('git') || constraint.startsWith('http') || diff --git a/app/composables/useVulnerabilityTree.ts b/app/composables/useVulnerabilityTree.ts new file mode 100644 index 0000000000..223be0a711 --- /dev/null +++ b/app/composables/useVulnerabilityTree.ts @@ -0,0 +1,51 @@ +import type { VulnerabilityTreeResult } from '#shared/types/osv' + +/** + * Shared composable for vulnerability tree data. + * Fetches once and caches the result so multiple components can use it. + */ +export function useVulnerabilityTree( + packageName: MaybeRefOrGetter, + version: MaybeRefOrGetter, +) { + // Build a stable key from the current values + const name = toValue(packageName) + const ver = toValue(version) + const key = `vuln-tree:v1:${name}@${ver}` + + // Use useState for SSR-safe caching across components + const data = useState(key, () => null) + const status = useState<'idle' | 'pending' | 'success' | 'error'>(`${key}:status`, () => 'idle') + const error = useState(`${key}:error`, () => null) + + async function fetch() { + const pkgName = toValue(packageName) + const pkgVersion = toValue(version) + + if (!pkgName || !pkgVersion) return + + // Already fetched or fetching + if (status.value === 'success' || status.value === 'pending') return + + status.value = 'pending' + error.value = null + + try { + const result = await $fetch( + `/api/registry/vulnerabilities/${encodePackageName(pkgName)}/v/${pkgVersion}`, + ) + data.value = result + status.value = 'success' + } catch (e) { + error.value = e instanceof Error ? e : new Error('Failed to fetch vulnerabilities') + status.value = 'error' + } + } + + return { + data: readonly(data), + status: readonly(status), + error: readonly(error), + fetch, + } +} diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue index d9e6dd77d5..e9197efae5 100644 --- a/app/pages/[...package].vue +++ b/app/pages/[...package].vue @@ -811,7 +811,7 @@ defineOgImageComponent('Package', { /> - + Run @@ -1071,6 +1071,17 @@ defineOgImageComponent('Package', { + + + + + + + diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 51eeef3ac4..f09a0938a6 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -218,8 +218,23 @@ "vulnerabilities": { "no_description": "No description available", "found": "{count} vulnerability found | {count} vulnerabilities found", + "deps_found": "{count} vulnerability found | {count} vulnerabilities found", + "deps_affected": "{count} dependency affected | {count} dependencies affected", + "tree_found": "{vulns} vulnerability in {packages}/{total} packages | {vulns} vulnerabilities in {packages}/{total} packages", + "scanning_tree": "Scanning dependency tree...", + "show_all_packages": "show all {count} affected packages", "no_summary": "No summary", "view_details": "View vulnerability details", + "path": "path", + "more": "+{count} more", + "packages_failed": "{count} package could not be checked | {count} packages could not be checked", + "no_known": "No known vulnerabilities in {count} packages", + "scan_failed": "Could not scan for vulnerabilities", + "depth": { + "root": "This package", + "direct": "Direct dependency", + "transitive": "Transitive dependency (indirect)" + }, "severity": { "critical": "critical", "high": "high", diff --git a/i18n/locales/fr.json b/i18n/locales/fr.json index 710b9dacf4..31f7babc5d 100644 --- a/i18n/locales/fr.json +++ b/i18n/locales/fr.json @@ -208,8 +208,23 @@ "vulnerabilities": { "no_description": "Aucune description disponible", "found": "{count} vulnérabilité trouvée | {count} vulnérabilités trouvées", + "deps_found": "{count} vulnérabilité trouvée | {count} vulnérabilités trouvées", + "deps_affected": "{count} dépendance affectée | {count} dépendances affectées", + "tree_found": "{vulns} vulnérabilité dans {packages}/{total} paquets | {vulns} vulnérabilités dans {packages}/{total} paquets", + "scanning_tree": "Analyse de l'arbre des dépendances...", + "show_all_packages": "afficher les {count} paquets affectés", "no_summary": "Aucun résumé", "view_details": "Voir les détails de la vulnérabilité", + "path": "chemin", + "more": "+{count} de plus", + "packages_failed": "{count} paquet n'a pas pu être vérifié | {count} paquets n'ont pas pu être vérifiés", + "no_known": "Aucune vulnérabilité connue dans {count} paquets", + "scan_failed": "Impossible d'analyser les vulnérabilités", + "depth": { + "root": "Ce paquet", + "direct": "Dépendance directe", + "transitive": "Dépendance transitive (indirecte)" + }, "severity": { "critical": "critique", "high": "élevée", diff --git a/i18n/locales/it.json b/i18n/locales/it.json index 434ce41324..9d02c2fe38 100644 --- a/i18n/locales/it.json +++ b/i18n/locales/it.json @@ -195,9 +195,24 @@ }, "vulnerabilities": { "no_description": "Nessuna descrizione disponibile", - "found": "{count} vulnerabilità trovate", + "found": "{count} vulnerabilità trovata | {count} vulnerabilità trovate", + "deps_found": "{count} vulnerabilità trovata | {count} vulnerabilità trovate", + "deps_affected": "{count} dipendenza interessata | {count} dipendenze interessate", + "tree_found": "{vulns} vulnerabilità in {packages}/{total} pacchetti | {vulns} vulnerabilità in {packages}/{total} pacchetti", + "scanning_tree": "Scansione dell'albero delle dipendenze...", + "show_all_packages": "mostra tutti i {count} pacchetti interessati", "no_summary": "Nessun riassunto", "view_details": "Vedi dettagli sulle vulnerabilitá", + "path": "percorso", + "more": "+{count} altri", + "packages_failed": "{count} pacchetto non ha potuto essere verificato | {count} pacchetti non hanno potuto essere verificati", + "no_known": "Nessuna vulnerabilità nota in {count} pacchetti", + "scan_failed": "Impossibile analizzare le vulnerabilità", + "depth": { + "root": "Questo pacchetto", + "direct": "Dipendenza diretta", + "transitive": "Dipendenza transitiva (indiretta)" + }, "severity": { "critical": "critica", "high": "alta", diff --git a/i18n/locales/zh-CN.json b/i18n/locales/zh-CN.json index 93bcd03e5a..b5da7ace2f 100644 --- a/i18n/locales/zh-CN.json +++ b/i18n/locales/zh-CN.json @@ -218,8 +218,23 @@ "vulnerabilities": { "no_description": "没有可用的描述", "found": "{count} 个漏洞", + "deps_found": "{count} 个漏洞", + "deps_affected": "{count} 个受影响的依赖", + "tree_found": "在 {packages}/{total} 个包中发现 {vulns} 个漏洞", + "scanning_tree": "正在扫描依赖树...", + "show_all_packages": "显示全部 {count} 个受影响的包", "no_summary": "没有总结", "view_details": "查看漏洞详情", + "path": "路径", + "more": "+{count} 更多", + "packages_failed": "{count} 个包无法检查", + "no_known": "在 {count} 个包中未发现已知漏洞", + "scan_failed": "无法扫描漏洞", + "depth": { + "root": "此包", + "direct": "直接依赖", + "transitive": "间接依赖(传递性)" + }, "severity": { "critical": "严重", "high": "高", diff --git a/server/api/registry/vulnerabilities/[...pkg].get.ts b/server/api/registry/vulnerabilities/[...pkg].get.ts new file mode 100644 index 0000000000..d9c627deb6 --- /dev/null +++ b/server/api/registry/vulnerabilities/[...pkg].get.ts @@ -0,0 +1,50 @@ +import * as v from 'valibot' +import { PackageRouteParamsSchema } from '#shared/schemas/package' +import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants' + +/** + * GET /api/registry/vulnerabilities/:name or /api/registry/vulnerabilities/:name/v/:version + * + * Analyze entire dependency tree for vulnerabilities. + */ +export default defineCachedEventHandler( + async event => { + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] + const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) + + try { + const { packageName, version: requestedVersion } = v.parse(PackageRouteParamsSchema, { + packageName: rawPackageName, + version: rawVersion, + }) + + // If no version specified, resolve to latest + let version = requestedVersion + if (!version) { + const packument = await fetchNpmPackage(packageName) + version = packument['dist-tags']?.latest + if (!version) { + throw createError({ + statusCode: 404, + message: 'No latest version found', + }) + } + } + + return await analyzeVulnerabilityTree(packageName, version) + } catch (error: unknown) { + handleApiError(error, { + statusCode: 502, + message: 'Failed to analyze vulnerabilities', + }) + } + }, + { + maxAge: CACHE_MAX_AGE_ONE_HOUR, + swr: true, + getKey: event => { + const pkg = getRouterParam(event, 'pkg') ?? '' + return `vulnerabilities:v1:${pkg.replace(/\/+$/, '').trim()}` + }, + }, +) diff --git a/server/utils/dependency-resolver.ts b/server/utils/dependency-resolver.ts new file mode 100644 index 0000000000..81040e8c00 --- /dev/null +++ b/server/utils/dependency-resolver.ts @@ -0,0 +1,203 @@ +import type { Packument, PackumentVersion, DependencyDepth } from '#shared/types' +import { maxSatisfying } from 'semver' + +/** + * Target platform for dependency resolution. + * We resolve for linux-x64 with glibc as a representative platform. + */ +export const TARGET_PLATFORM = { + os: 'linux', + cpu: 'x64', + libc: 'glibc', +} + +/** + * Fetch packument with caching (returns null on error for tree traversal) + */ +export const fetchPackument = defineCachedFunction( + async (name: string): Promise => { + try { + const encodedName = name.startsWith('@') + ? `@${encodeURIComponent(name.slice(1))}` + : encodeURIComponent(name) + + return await $fetch(`https://registry.npmjs.org/${encodedName}`) + } catch (error) { + // oxlint-disable-next-line no-console -- log npm registry failures for debugging + if (import.meta.dev) { + console.warn(`[dep-resolver] Failed to fetch packument for ${name}:`, error) + } + return null + } + }, + { + maxAge: 60 * 60, + swr: true, + name: 'packument', + getKey: (name: string) => name, + }, +) + +/** + * Check if a package version matches the target platform. + * Returns false if the package explicitly excludes our target platform. + */ +export function matchesPlatform(version: PackumentVersion): boolean { + if (version.os && Array.isArray(version.os) && version.os.length > 0) { + const osMatch = version.os.some(os => { + if (os.startsWith('!')) return os.slice(1) !== TARGET_PLATFORM.os + return os === TARGET_PLATFORM.os + }) + if (!osMatch) return false + } + + if (version.cpu && Array.isArray(version.cpu) && version.cpu.length > 0) { + const cpuMatch = version.cpu.some(cpu => { + if (cpu.startsWith('!')) return cpu.slice(1) !== TARGET_PLATFORM.cpu + return cpu === TARGET_PLATFORM.cpu + }) + if (!cpuMatch) return false + } + + const libc = (version as { libc?: string[] }).libc + if (libc && Array.isArray(libc) && libc.length > 0) { + const libcMatch = libc.some(l => { + if (l.startsWith('!')) return l.slice(1) !== TARGET_PLATFORM.libc + return l === TARGET_PLATFORM.libc + }) + if (!libcMatch) return false + } + + return true +} + +/** + * Resolve a semver range to a specific version from available versions. + */ +export function resolveVersion(range: string, versions: string[]): string | null { + if (versions.includes(range)) return range + + // Handle npm: protocol (aliases) + if (range.startsWith('npm:')) { + const atIndex = range.lastIndexOf('@') + if (atIndex > 4) { + return resolveVersion(range.slice(atIndex + 1), versions) + } + return null + } + + // Handle URLs, git refs, etc. - we can't resolve these + if ( + range.startsWith('http://') || + range.startsWith('https://') || + range.startsWith('git://') || + range.startsWith('git+') || + range.startsWith('file:') || + range.includes('/') + ) { + return null + } + + return maxSatisfying(versions, range) +} + +/** Resolved package info */ +export interface ResolvedPackage { + name: string + version: string + size: number + optional: boolean + /** Depth level (only when trackDepth is enabled) */ + depth?: DependencyDepth + /** Dependency path from root (only when trackDepth is enabled) */ + path?: string[] +} + +/** + * Resolve the entire dependency tree for a package. + * Uses level-by-level BFS to ensure correct depth assignment when trackDepth is enabled. + */ +export async function resolveDependencyTree( + rootName: string, + rootVersion: string, + options: { trackDepth?: boolean } = {}, +): Promise> { + const resolved = new Map() + const seen = new Set() + + // Process level by level for correct depth tracking + // Each entry includes the path of package names leading to this dependency + let currentLevel = new Map([ + [rootName, { range: rootVersion, optional: false, path: [] }], + ]) + let level = 0 + + while (currentLevel.size > 0) { + const nextLevel = new Map() + + // Mark all packages in current level as seen before processing + for (const name of currentLevel.keys()) { + seen.add(name) + } + + // Process current level in batches + const entries = [...currentLevel.entries()] + for (let i = 0; i < entries.length; i += 20) { + const batch = entries.slice(i, i + 20) + + await Promise.all( + batch.map(async ([name, { range, optional, path }]) => { + const packument = await fetchPackument(name) + if (!packument) return + + const versions = Object.keys(packument.versions) + const version = resolveVersion(range, versions) + if (!version) return + + const versionData = packument.versions[version] + if (!versionData) return + + if (!matchesPlatform(versionData)) return + + const size = (versionData.dist as { unpackedSize?: number })?.unpackedSize ?? 0 + const key = `${name}@${version}` + + // Build path for this package (path to parent + this package with version) + const currentPath = [...path, `${name}@${version}`] + + if (!resolved.has(key)) { + const pkg: ResolvedPackage = { name, version, size, optional } + if (options.trackDepth) { + pkg.depth = level === 0 ? 'root' : level === 1 ? 'direct' : 'transitive' + pkg.path = currentPath + } + resolved.set(key, pkg) + } + + // Collect dependencies for next level + if (versionData.dependencies) { + for (const [depName, depRange] of Object.entries(versionData.dependencies)) { + if (!seen.has(depName) && !nextLevel.has(depName)) { + nextLevel.set(depName, { range: depRange, optional: false, path: currentPath }) + } + } + } + + // Collect optional dependencies + if (versionData.optionalDependencies) { + for (const [depName, depRange] of Object.entries(versionData.optionalDependencies)) { + if (!seen.has(depName) && !nextLevel.has(depName)) { + nextLevel.set(depName, { range: depRange, optional: true, path: currentPath }) + } + } + } + }), + ) + } + + currentLevel = nextLevel + level++ + } + + return resolved +} diff --git a/server/utils/install-size.ts b/server/utils/install-size.ts index 89989d73f2..7705af450c 100644 --- a/server/utils/install-size.ts +++ b/server/utils/install-size.ts @@ -1,6 +1,3 @@ -import type { Packument, PackumentVersion } from '#shared/types' -import { maxSatisfying } from 'semver' - /** * Result of install size calculation */ @@ -27,193 +24,6 @@ export interface DependencySize { optional?: boolean } -/** - * We resolve for linux-x64 with glibc - */ -const TARGET_PLATFORM = { - os: 'linux', - cpu: 'x64', - libc: 'glibc', -} - -const fetchPackument = defineCachedFunction( - async (name: string): Promise => { - try { - const encodedName = name.startsWith('@') - ? `@${encodeURIComponent(name.slice(1))}` - : encodeURIComponent(name) - - return await $fetch(`https://registry.npmjs.org/${encodedName}`) - } catch { - return null - } - }, - { - maxAge: 60 * 60, // 1 hour - swr: true, - name: 'packument', - getKey: (name: string) => name, - }, -) - -/** - * Check if a package version matches the target platform. - * Returns false if the package explicitly excludes our target platform. - */ -function matchesPlatform(version: PackumentVersion): boolean { - // Check OS compatibility - if (version.os && Array.isArray(version.os) && version.os.length > 0) { - const osMatch = version.os.some(os => { - if (os.startsWith('!')) { - return os.slice(1) !== TARGET_PLATFORM.os - } - return os === TARGET_PLATFORM.os - }) - if (!osMatch) return false - } - - // Check CPU compatibility - if (version.cpu && Array.isArray(version.cpu) && version.cpu.length > 0) { - const cpuMatch = version.cpu.some(cpu => { - if (cpu.startsWith('!')) { - return cpu.slice(1) !== TARGET_PLATFORM.cpu - } - return cpu === TARGET_PLATFORM.cpu - }) - if (!cpuMatch) return false - } - - // Check libc compatibility (if specified) - const libc = (version as { libc?: string[] }).libc - if (libc && Array.isArray(libc) && libc.length > 0) { - const libcMatch = libc.some(l => { - if (l.startsWith('!')) { - return l.slice(1) !== TARGET_PLATFORM.libc - } - return l === TARGET_PLATFORM.libc - }) - if (!libcMatch) return false - } - - return true -} - -/** - * Resolve a semver range to a specific version from available versions. - */ -function resolveVersion(range: string, versions: string[]): string | null { - // Handle exact versions, tags, URLs, etc. - if (versions.includes(range)) { - return range - } - - // Handle npm: protocol (aliases) - if (range.startsWith('npm:')) { - // npm:package@version - extract the version part - const atIndex = range.lastIndexOf('@') - if (atIndex > 4) { - // After 'npm:' - const aliasedRange = range.slice(atIndex + 1) - return resolveVersion(aliasedRange, versions) - } - return null - } - - // Handle URLs, git refs, etc. - we can't resolve these - if ( - range.startsWith('http://') || - range.startsWith('https://') || - range.startsWith('git://') || - range.startsWith('git+') || - range.startsWith('file:') || - range.includes('/') - ) { - return null - } - - return maxSatisfying(versions, range) -} - -interface ResolvedDep { - name: string - version: string - size: number - optional: boolean -} - -/** - * Recursively resolve dependencies for a package. - * Uses a breadth-first approach with deduplication. - */ -async function resolveDependencyTree( - rootName: string, - rootVersion: string, -): Promise> { - const resolved = new Map() - const queue: Array<{ - name: string - range: string - optional: boolean - }> = [{ name: rootName, range: rootVersion, optional: false }] - const seen = new Set() - - while (queue.length > 0) { - // Process in batches for better parallelism - const batch = queue.splice(0, Math.min(20, queue.length)) - - await Promise.all( - batch.map(async ({ name, range, optional }) => { - // Skip if we've already resolved this package - // (deduplication - use the first version we encounter) - if (seen.has(name)) return - seen.add(name) - - const packument = await fetchPackument(name) - if (!packument) return - - const versions = Object.keys(packument.versions) - const version = resolveVersion(range, versions) - if (!version) return - - const versionData = packument.versions[version] - if (!versionData) return - - // Skip if this package doesn't match our target platform - if (!matchesPlatform(versionData)) return - - // Get unpacked size - const size = (versionData.dist as { unpackedSize?: number })?.unpackedSize ?? 0 - - const key = `${name}@${version}` - if (!resolved.has(key)) { - resolved.set(key, { name, version, size, optional }) - } - - // Queue regular dependencies - if (versionData.dependencies) { - for (const [depName, depRange] of Object.entries(versionData.dependencies)) { - if (!seen.has(depName)) { - queue.push({ name: depName, range: depRange, optional: false }) - } - } - } - - // Queue optional dependencies (but mark them as optional) - // Only include if they match our platform - if (versionData.optionalDependencies) { - for (const [depName, depRange] of Object.entries(versionData.optionalDependencies)) { - if (!seen.has(depName)) { - queue.push({ name: depName, range: depRange, optional: true }) - } - } - } - }), - ) - } - - return resolved -} - /** * Calculate the total install size for a package. * diff --git a/server/utils/vulnerability-tree.ts b/server/utils/vulnerability-tree.ts new file mode 100644 index 0000000000..c8ff57f2c4 --- /dev/null +++ b/server/utils/vulnerability-tree.ts @@ -0,0 +1,184 @@ +import type { + OsvQueryResponse, + OsvVulnerability, + OsvSeverityLevel, + VulnerabilitySummary, + DependencyDepth, + PackageVulnerabilityInfo, + VulnerabilityTreeResult, +} from '#shared/types' +import { resolveDependencyTree } from './dependency-resolver' + +/** Result of a single OSV query */ +type OsvQueryResult = { status: 'ok'; data: PackageVulnerabilityInfo | null } | { status: 'error' } + +/** + * Query OSV for vulnerabilities in a package + */ +async function queryOsv( + name: string, + version: string, + depth: DependencyDepth, + path: string[], +): Promise { + try { + const response = await $fetch('https://api.osv.dev/v1/query', { + method: 'POST', + body: { + package: { name, ecosystem: 'npm' }, + version, + }, + }) + + const vulns = response.vulns || [] + if (vulns.length === 0) return { status: 'ok', data: 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), + }) + } + + return { status: 'ok', data: { name, version, depth, path, vulnerabilities, counts } } + } catch (error) { + // oxlint-disable-next-line no-console -- log OSV API failures for debugging + console.warn(`[vuln-tree] OSV query failed for ${name}@${version}:`, error) + return { status: 'error' } + } +} + +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}` +} + +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. + */ +export const analyzeVulnerabilityTree = defineCachedFunction( + async (name: string, version: string): Promise => { + // Resolve all packages in the tree with depth tracking + const resolved = await resolveDependencyTree(name, version, { trackDepth: true }) + + // Convert to array for OSV querying + const packages = [...resolved.values()] + + // Query OSV for all packages in parallel batches + const vulnerablePackages: PackageVulnerabilityInfo[] = [] + let failedQueries = 0 + const batchSize = 10 + + for (let i = 0; i < packages.length; i += batchSize) { + const batch = packages.slice(i, i + batchSize) + const results = await Promise.all( + batch.map(pkg => queryOsv(pkg.name, pkg.version, pkg.depth!, pkg.path || [])), + ) + + for (const result of results) { + if (result.status === 'error') { + failedQueries++ + } else if (result.data) { + vulnerablePackages.push(result.data) + } + } + } + + // Sort by depth (root → direct → transitive), then by severity + const depthOrder: Record = { root: 0, direct: 1, transitive: 2 } + vulnerablePackages.sort((a, b) => { + if (a.depth !== b.depth) return depthOrder[a.depth] - depthOrder[b.depth] + if (a.counts.critical !== b.counts.critical) return b.counts.critical - a.counts.critical + if (a.counts.high !== b.counts.high) return b.counts.high - a.counts.high + if (a.counts.moderate !== b.counts.moderate) return b.counts.moderate - a.counts.moderate + return b.counts.total - a.counts.total + }) + + // Aggregate total counts + const totalCounts = { total: 0, critical: 0, high: 0, moderate: 0, low: 0 } + for (const pkg of vulnerablePackages) { + totalCounts.total += pkg.counts.total + totalCounts.critical += pkg.counts.critical + totalCounts.high += pkg.counts.high + totalCounts.moderate += pkg.counts.moderate + totalCounts.low += pkg.counts.low + } + + // Log critical failures (>50% of queries failed) + if (failedQueries > 0 && failedQueries > packages.length / 2) { + // oxlint-disable-next-line no-console -- critical error logging + console.error( + `[vuln-tree] Critical: ${failedQueries}/${packages.length} OSV queries failed for ${name}@${version}`, + ) + } + + return { + package: name, + version, + vulnerablePackages, + totalPackages: packages.length, + failedQueries, + totalCounts, + } + }, + { + maxAge: 60 * 60, + swr: true, + name: 'vulnerability-tree', + getKey: (name: string, version: string) => `v1:${name}@${version}`, + }, +) diff --git a/shared/types/osv.ts b/shared/types/osv.ts index fb21f606ed..758e3815e5 100644 --- a/shared/types/osv.ts +++ b/shared/types/osv.ts @@ -3,10 +3,20 @@ * @see https://google.github.io/osv.dev/api/ */ +/** + * Severity levels in priority order (highest first) + */ +export const SEVERITY_LEVELS = ['critical', 'high', 'moderate', 'low'] as const + /** * Severity level derived from CVSS score */ -export type OsvSeverityLevel = 'critical' | 'high' | 'moderate' | 'low' | 'unknown' +export type OsvSeverityLevel = (typeof SEVERITY_LEVELS)[number] | 'unknown' + +/** + * Counts by severity level + */ +export type SeverityCounts = Record<(typeof SEVERITY_LEVELS)[number], number> /** * CVSS severity information from OSV @@ -70,6 +80,23 @@ export interface PackageVulnerabilities { package: string version: string vulnerabilities: VulnerabilitySummary[] + counts: SeverityCounts & { total: number } +} + +/** Depth in dependency tree */ +export type DependencyDepth = 'root' | 'direct' | 'transitive' + +/** + * Vulnerability info for a single package in the tree + */ +export interface PackageVulnerabilityInfo { + name: string + version: string + /** Depth in dependency tree: root (0), direct (1), transitive (2+) */ + depth: DependencyDepth + /** Dependency path from root package */ + path: string[] + vulnerabilities: VulnerabilitySummary[] counts: { total: number critical: number @@ -78,3 +105,27 @@ export interface PackageVulnerabilities { low: number } } + +/** + * Result of vulnerability tree analysis + */ +export interface VulnerabilityTreeResult { + /** Root package name */ + package: string + /** Root package version */ + version: string + /** All packages with vulnerabilities in the tree */ + vulnerablePackages: PackageVulnerabilityInfo[] + /** Total packages analyzed */ + totalPackages: number + /** Number of packages that could not be checked (OSV query failed) */ + failedQueries: number + /** Aggregated counts across all packages */ + totalCounts: { + total: number + critical: number + high: number + moderate: number + low: number + } +} diff --git a/shared/utils/severity.ts b/shared/utils/severity.ts new file mode 100644 index 0000000000..1489b64017 --- /dev/null +++ b/shared/utils/severity.ts @@ -0,0 +1,45 @@ +import type { OsvSeverityLevel } from '../types' +import { SEVERITY_LEVELS } from '../types' + +/** + * Color classes for severity levels (banner style) + */ +export const SEVERITY_COLORS: Record = { + critical: 'text-red-300 bg-red-500/15 border-red-500/40', + high: 'text-red-400 bg-red-500/10 border-red-500/30', + moderate: 'text-orange-400 bg-orange-500/10 border-orange-500/30', + low: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30', + unknown: 'text-fg-muted bg-bg-subtle border-border', +} + +/** + * Color classes for inline severity indicators + */ +export const SEVERITY_TEXT_COLORS: Record = { + critical: 'text-red-500', + high: 'text-orange-500', + moderate: 'text-yellow-500', + low: 'text-blue-500', + unknown: 'text-fg-subtle', +} + +/** + * Badge color classes for severity levels + */ +export const SEVERITY_BADGE_COLORS: Record = { + critical: 'bg-bg-muted border border-border text-fg', + high: 'bg-bg-muted border border-border text-fg-muted', + moderate: 'bg-bg-muted border border-border text-fg-muted', + low: 'bg-bg-muted border border-border text-fg-subtle', + unknown: 'bg-bg-muted border border-border text-fg-subtle', +} + +/** + * Get highest severity from counts + */ +export function getHighestSeverity(counts: Record): OsvSeverityLevel { + for (const s of SEVERITY_LEVELS) { + if ((counts[s] ?? 0) > 0) return s + } + return 'unknown' +} diff --git a/test/nuxt/components.spec.ts b/test/nuxt/components.spec.ts index ae2385743f..ef1803d90e 100644 --- a/test/nuxt/components.spec.ts +++ b/test/nuxt/components.spec.ts @@ -77,11 +77,12 @@ import ClaimPackageModal from '~/components/ClaimPackageModal.vue' import OperationsQueue from '~/components/OperationsQueue.vue' import PackageList from '~/components/PackageList.vue' import PackageMetricsBadges from '~/components/PackageMetricsBadges.vue' -import PackageVulnerabilities from '~/components/PackageVulnerabilities.vue' import PackageAccessControls from '~/components/PackageAccessControls.vue' import OrgMembersPanel from '~/components/OrgMembersPanel.vue' import OrgTeamsPanel from '~/components/OrgTeamsPanel.vue' import CodeMobileTreeDrawer from '~/components/CodeMobileTreeDrawer.vue' +import PackageVulnerabilityTree from '~/components/PackageVulnerabilityTree.vue' +import DependencyPathPopup from '~/components/DependencyPathPopup.vue' describe('component accessibility audits', () => { describe('DateTime', () => { @@ -432,7 +433,7 @@ describe('component accessibility audits', () => { describe('PackageDependencies', () => { it('should have no accessibility violations without dependencies', async () => { const component = await mountSuspended(PackageDependencies, { - props: { packageName: 'test-package' }, + props: { packageName: 'test-package', version: '1.0.0' }, }) const results = await runAxe(component) expect(results.violations).toEqual([]) @@ -442,6 +443,7 @@ describe('component accessibility audits', () => { const component = await mountSuspended(PackageDependencies, { props: { packageName: 'test-package', + version: '1.0.0', dependencies: { vue: '^3.0.0', lodash: '^4.17.0', @@ -456,6 +458,7 @@ describe('component accessibility audits', () => { const component = await mountSuspended(PackageDependencies, { props: { packageName: 'test-package', + version: '1.0.0', peerDependencies: { vue: '^3.0.0', }, @@ -816,19 +819,6 @@ describe('component accessibility audits', () => { }) }) - describe('PackageVulnerabilities', () => { - it('should have no accessibility violations', async () => { - const component = await mountSuspended(PackageVulnerabilities, { - props: { - packageName: 'lodash', - version: '4.17.21', - }, - }) - const results = await runAxe(component) - expect(results.violations).toEqual([]) - }) - }) - describe('PackageAccessControls', () => { it('should have no accessibility violations', async () => { const component = await mountSuspended(PackageAccessControls, { @@ -891,4 +881,39 @@ describe('component accessibility audits', () => { expect(results.violations).toEqual([]) }) }) + + describe('PackageVulnerabilityTree', () => { + it('should have no accessibility violations in idle state', async () => { + const component = await mountSuspended(PackageVulnerabilityTree, { + props: { + packageName: 'vue', + version: '3.5.0', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + + describe('DependencyPathPopup', () => { + it('should have no accessibility violations with short path', async () => { + const component = await mountSuspended(DependencyPathPopup, { + props: { + path: ['root@1.0.0', 'vuln-dep@2.0.0'], + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with deep path', async () => { + const component = await mountSuspended(DependencyPathPopup, { + props: { + path: ['root@1.0.0', 'dep-a@1.0.0', 'dep-b@2.0.0', 'dep-c@3.0.0', 'vulnerable-pkg@4.0.0'], + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) }) diff --git a/test/unit/dependency-resolver.spec.ts b/test/unit/dependency-resolver.spec.ts new file mode 100644 index 0000000000..831eb6bc49 --- /dev/null +++ b/test/unit/dependency-resolver.spec.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from 'vitest' +import type { PackumentVersion } from '../../shared/types' + +// Mock Nitro globals before importing the module +vi.stubGlobal('defineCachedFunction', (fn: Function) => fn) +vi.stubGlobal('$fetch', vi.fn()) + +const { TARGET_PLATFORM, matchesPlatform, resolveVersion } = + await import('../../server/utils/dependency-resolver') + +describe('dependency-resolver', () => { + describe('TARGET_PLATFORM', () => { + it('is configured for linux-x64-glibc', () => { + expect(TARGET_PLATFORM).toEqual({ + os: 'linux', + cpu: 'x64', + libc: 'glibc', + }) + }) + }) + + describe('matchesPlatform', () => { + it('returns true for packages without platform restrictions', () => { + const version = {} as PackumentVersion + expect(matchesPlatform(version)).toBe(true) + }) + + it('returns true when os includes linux', () => { + const version = { os: ['linux', 'darwin'] } as PackumentVersion + expect(matchesPlatform(version)).toBe(true) + }) + + it('returns false when os excludes linux', () => { + const version = { os: ['darwin', 'win32'] } as PackumentVersion + expect(matchesPlatform(version)).toBe(false) + }) + + it('handles negated os values (!linux)', () => { + const version = { os: ['!win32'] } as PackumentVersion + expect(matchesPlatform(version)).toBe(true) + + const excluded = { os: ['!linux'] } as PackumentVersion + expect(matchesPlatform(excluded)).toBe(false) + }) + + it('returns true when cpu includes x64', () => { + const version = { cpu: ['x64', 'arm64'] } as PackumentVersion + expect(matchesPlatform(version)).toBe(true) + }) + + it('returns false when cpu excludes x64', () => { + const version = { cpu: ['arm64', 'arm'] } as PackumentVersion + expect(matchesPlatform(version)).toBe(false) + }) + + it('handles negated cpu values (!x64)', () => { + const version = { cpu: ['!arm64'] } as PackumentVersion + expect(matchesPlatform(version)).toBe(true) + + const excluded = { cpu: ['!x64'] } as PackumentVersion + expect(matchesPlatform(excluded)).toBe(false) + }) + + it('returns true when libc includes glibc', () => { + const version = { libc: ['glibc'] } as unknown as PackumentVersion + expect(matchesPlatform(version)).toBe(true) + }) + + it('returns false when libc is musl only', () => { + const version = { libc: ['musl'] } as unknown as PackumentVersion + expect(matchesPlatform(version)).toBe(false) + }) + + it('handles negated libc values (!glibc)', () => { + const version = { libc: ['!musl'] } as unknown as PackumentVersion + expect(matchesPlatform(version)).toBe(true) + + const excluded = { libc: ['!glibc'] } as unknown as PackumentVersion + expect(matchesPlatform(excluded)).toBe(false) + }) + + it('requires all platform constraints to match', () => { + const version = { + os: ['linux'], + cpu: ['arm64'], // doesn't match x64 + } as PackumentVersion + expect(matchesPlatform(version)).toBe(false) + }) + + it('ignores empty arrays', () => { + const version = { os: [], cpu: [], libc: [] } as unknown as PackumentVersion + expect(matchesPlatform(version)).toBe(true) + }) + }) + + describe('resolveVersion', () => { + const versions = ['1.0.0', '1.0.1', '1.1.0', '2.0.0', '2.0.0-beta.1', '3.0.0'] + + it('returns exact version if it exists', () => { + expect(resolveVersion('1.0.0', versions)).toBe('1.0.0') + expect(resolveVersion('2.0.0', versions)).toBe('2.0.0') + }) + + it('returns null for exact version that does not exist', () => { + expect(resolveVersion('1.0.2', versions)).toBe(null) + }) + + it('resolves semver ranges', () => { + expect(resolveVersion('^1.0.0', versions)).toBe('1.1.0') + expect(resolveVersion('~1.0.0', versions)).toBe('1.0.1') + expect(resolveVersion('>=2.0.0', versions)).toBe('3.0.0') + expect(resolveVersion('<2.0.0', versions)).toBe('1.1.0') + }) + + it('resolves * to latest stable', () => { + expect(resolveVersion('*', versions)).toBe('3.0.0') + }) + + it('handles npm: protocol aliases', () => { + expect(resolveVersion('npm:other-pkg@^1.0.0', versions)).toBe('1.1.0') + expect(resolveVersion('npm:@scope/pkg@2.0.0', versions)).toBe('2.0.0') + }) + + it('returns null for invalid npm: protocol', () => { + expect(resolveVersion('npm:', versions)).toBe(null) + expect(resolveVersion('npm:pkg', versions)).toBe(null) + }) + + it('returns null for URLs', () => { + expect(resolveVersion('https://github.com/user/repo', versions)).toBe(null) + expect(resolveVersion('http://example.com/pkg.tgz', versions)).toBe(null) + expect(resolveVersion('git://github.com/user/repo.git', versions)).toBe(null) + expect(resolveVersion('git+https://github.com/user/repo.git', versions)).toBe(null) + }) + + it('returns null for file: protocol', () => { + expect(resolveVersion('file:../local-pkg', versions)).toBe(null) + }) + + it('returns null for GitHub shorthand (contains /)', () => { + expect(resolveVersion('user/repo', versions)).toBe(null) + expect(resolveVersion('user/repo#branch', versions)).toBe(null) + }) + + it('handles prerelease versions when explicitly requested', () => { + // Exact prerelease version match + expect(resolveVersion('2.0.0-beta.1', versions)).toBe('2.0.0-beta.1') + // Range with prerelease - semver correctly prefers stable 2.0.0 over 2.0.0-beta.1 + expect(resolveVersion('^2.0.0-beta.0', versions)).toBe('2.0.0') + }) + }) +}) diff --git a/test/unit/severity.spec.ts b/test/unit/severity.spec.ts new file mode 100644 index 0000000000..70123eee75 --- /dev/null +++ b/test/unit/severity.spec.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { + SEVERITY_COLORS, + SEVERITY_TEXT_COLORS, + SEVERITY_BADGE_COLORS, + getHighestSeverity, +} from '../../shared/utils/severity' + +describe('severity utils', () => { + describe('SEVERITY_COLORS', () => { + it('has colors for all severity levels', () => { + expect(SEVERITY_COLORS.critical).toBeDefined() + expect(SEVERITY_COLORS.high).toBeDefined() + expect(SEVERITY_COLORS.moderate).toBeDefined() + expect(SEVERITY_COLORS.low).toBeDefined() + expect(SEVERITY_COLORS.unknown).toBeDefined() + }) + + it('critical has red colors', () => { + expect(SEVERITY_COLORS.critical).toContain('red') + }) + + it('high has red colors', () => { + expect(SEVERITY_COLORS.high).toContain('red') + }) + + it('moderate has orange colors', () => { + expect(SEVERITY_COLORS.moderate).toContain('orange') + }) + + it('low has yellow colors', () => { + expect(SEVERITY_COLORS.low).toContain('yellow') + }) + }) + + describe('SEVERITY_TEXT_COLORS', () => { + it('has text colors for all severity levels', () => { + expect(SEVERITY_TEXT_COLORS.critical).toContain('text-') + expect(SEVERITY_TEXT_COLORS.high).toContain('text-') + expect(SEVERITY_TEXT_COLORS.moderate).toContain('text-') + expect(SEVERITY_TEXT_COLORS.low).toContain('text-') + expect(SEVERITY_TEXT_COLORS.unknown).toContain('text-') + }) + }) + + describe('SEVERITY_BADGE_COLORS', () => { + it('has badge colors for all severity levels', () => { + expect(SEVERITY_BADGE_COLORS.critical).toBeDefined() + expect(SEVERITY_BADGE_COLORS.high).toBeDefined() + expect(SEVERITY_BADGE_COLORS.moderate).toBeDefined() + expect(SEVERITY_BADGE_COLORS.low).toBeDefined() + expect(SEVERITY_BADGE_COLORS.unknown).toBeDefined() + }) + }) + + describe('getHighestSeverity', () => { + it('returns critical when critical count > 0', () => { + expect(getHighestSeverity({ critical: 1, high: 0, moderate: 0, low: 0 })).toBe('critical') + }) + + it('returns high when high is highest', () => { + expect(getHighestSeverity({ critical: 0, high: 2, moderate: 1, low: 0 })).toBe('high') + }) + + it('returns moderate when moderate is highest', () => { + expect(getHighestSeverity({ critical: 0, high: 0, moderate: 3, low: 1 })).toBe('moderate') + }) + + it('returns low when only low', () => { + expect(getHighestSeverity({ critical: 0, high: 0, moderate: 0, low: 5 })).toBe('low') + }) + + it('returns unknown when all counts are 0', () => { + expect(getHighestSeverity({ critical: 0, high: 0, moderate: 0, low: 0 })).toBe('unknown') + }) + + it('returns unknown for empty object', () => { + expect(getHighestSeverity({})).toBe('unknown') + }) + + it('prioritizes critical over all others', () => { + expect(getHighestSeverity({ critical: 1, high: 10, moderate: 20, low: 30 })).toBe('critical') + }) + + it('handles missing keys gracefully', () => { + expect(getHighestSeverity({ high: 1 })).toBe('high') + expect(getHighestSeverity({ moderate: 1 })).toBe('moderate') + expect(getHighestSeverity({ low: 1 })).toBe('low') + }) + }) +}) diff --git a/test/unit/vulnerability-tree.spec.ts b/test/unit/vulnerability-tree.spec.ts new file mode 100644 index 0000000000..5090874c68 --- /dev/null +++ b/test/unit/vulnerability-tree.spec.ts @@ -0,0 +1,373 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest' + +// Mock Nitro globals before importing the module +vi.stubGlobal('defineCachedFunction', (fn: Function) => fn) +vi.stubGlobal('$fetch', vi.fn()) + +// Import module under test +const { analyzeVulnerabilityTree } = await import('../../server/utils/vulnerability-tree') + +// Mock the dependency resolver +vi.mock('../../server/utils/dependency-resolver', () => ({ + resolveDependencyTree: vi.fn(), +})) + +const { resolveDependencyTree } = await import('../../server/utils/dependency-resolver') + +describe('vulnerability-tree', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('analyzeVulnerabilityTree', () => { + it('returns empty result when no packages have vulnerabilities', async () => { + 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'], + }, + ], + ]) + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) + + // Mock OSV API returning no vulnerabilities + vi.mocked($fetch).mockResolvedValue({ vulns: [] }) + + const result = await analyzeVulnerabilityTree('test-pkg', '1.0.0') + + expect(result.package).toBe('test-pkg') + expect(result.version).toBe('1.0.0') + expect(result.vulnerablePackages).toHaveLength(0) + expect(result.totalPackages).toBe(1) + expect(result.failedQueries).toBe(0) + expect(result.totalCounts).toEqual({ total: 0, critical: 0, high: 0, moderate: 0, low: 0 }) + }) + + it('tracks failed queries when OSV API fails', async () => { + 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'], + }, + ], + [ + 'dep-a@2.0.0', + { + name: 'dep-a', + version: '2.0.0', + size: 500, + optional: false, + depth: 'direct' as const, + path: ['test-pkg@1.0.0', 'dep-a@2.0.0'], + }, + ], + ]) + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) + + // First call succeeds, second fails + vi.mocked($fetch) + .mockResolvedValueOnce({ vulns: [] }) + .mockRejectedValueOnce(new Error('OSV API error')) + + const result = await analyzeVulnerabilityTree('test-pkg', '1.0.0') + + expect(result.failedQueries).toBe(1) + expect(result.totalPackages).toBe(2) + }) + + it('correctly counts vulnerabilities by severity', async () => { + const mockResolved = new Map([ + [ + 'vuln-pkg@1.0.0', + { + name: 'vuln-pkg', + version: '1.0.0', + size: 1000, + optional: false, + depth: 'root' as const, + path: ['vuln-pkg@1.0.0'], + }, + ], + ]) + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) + + // Mock OSV API returning vulnerabilities with different severities + vi.mocked($fetch).mockResolvedValue({ + vulns: [ + { id: 'GHSA-1', summary: 'Critical vuln', database_specific: { severity: 'CRITICAL' } }, + { id: 'GHSA-2', summary: 'High vuln', database_specific: { severity: 'HIGH' } }, + { id: 'GHSA-3', summary: 'Moderate vuln', database_specific: { severity: 'MODERATE' } }, + { id: 'GHSA-4', summary: 'Low vuln', database_specific: { severity: 'LOW' } }, + ], + }) + + const result = await analyzeVulnerabilityTree('vuln-pkg', '1.0.0') + + expect(result.vulnerablePackages).toHaveLength(1) + expect(result.totalCounts).toEqual({ total: 4, critical: 1, high: 1, moderate: 1, low: 1 }) + + const pkg = result.vulnerablePackages[0] + expect(pkg.counts.critical).toBe(1) + expect(pkg.counts.high).toBe(1) + expect(pkg.counts.moderate).toBe(1) + expect(pkg.counts.low).toBe(1) + }) + + it('includes dependency path in vulnerable packages', async () => { + const mockResolved = new Map([ + [ + 'root@1.0.0', + { + name: 'root', + version: '1.0.0', + size: 1000, + optional: false, + depth: 'root' as const, + path: ['root@1.0.0'], + }, + ], + [ + 'vuln-dep@2.0.0', + { + name: 'vuln-dep', + version: '2.0.0', + size: 500, + optional: false, + depth: 'transitive' as const, + path: ['root@1.0.0', 'middle@1.5.0', 'vuln-dep@2.0.0'], + }, + ], + ]) + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) + + vi.mocked($fetch) + .mockResolvedValueOnce({ vulns: [] }) // root has no vulns + .mockResolvedValueOnce({ + vulns: [ + { id: 'GHSA-test', summary: 'Test vuln', database_specific: { severity: 'HIGH' } }, + ], + }) // vuln-dep has vuln + + const result = await analyzeVulnerabilityTree('root', '1.0.0') + + expect(result.vulnerablePackages).toHaveLength(1) + const vulnPkg = result.vulnerablePackages[0] + expect(vulnPkg.path).toEqual(['root@1.0.0', 'middle@1.5.0', 'vuln-dep@2.0.0']) + expect(vulnPkg.depth).toBe('transitive') + }) + + it('sorts vulnerable packages by depth then severity', async () => { + const mockResolved = new Map([ + [ + 'root@1.0.0', + { + name: 'root', + version: '1.0.0', + size: 1000, + optional: false, + depth: 'root' as const, + path: ['root@1.0.0'], + }, + ], + [ + 'direct-dep@1.0.0', + { + name: 'direct-dep', + version: '1.0.0', + size: 500, + optional: false, + depth: 'direct' as const, + path: ['root@1.0.0', 'direct-dep@1.0.0'], + }, + ], + [ + 'transitive-dep@1.0.0', + { + name: 'transitive-dep', + version: '1.0.0', + size: 300, + optional: false, + depth: 'transitive' as const, + path: ['root@1.0.0', 'direct-dep@1.0.0', 'transitive-dep@1.0.0'], + }, + ], + ]) + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) + + // All have vulnerabilities + vi.mocked($fetch) + .mockResolvedValueOnce({ + vulns: [ + { id: 'GHSA-root', summary: 'Root vuln', database_specific: { severity: 'LOW' } }, + ], + }) + .mockResolvedValueOnce({ + vulns: [ + { + id: 'GHSA-direct', + summary: 'Direct vuln', + database_specific: { severity: 'CRITICAL' }, + }, + ], + }) + .mockResolvedValueOnce({ + vulns: [ + { id: 'GHSA-trans', summary: 'Trans vuln', database_specific: { severity: 'HIGH' } }, + ], + }) + + const result = await analyzeVulnerabilityTree('root', '1.0.0') + + expect(result.vulnerablePackages).toHaveLength(3) + // Should be sorted: root first, then direct, then transitive + expect(result.vulnerablePackages[0].name).toBe('root') + expect(result.vulnerablePackages[1].name).toBe('direct-dep') + expect(result.vulnerablePackages[2].name).toBe('transitive-dep') + }) + + it('generates correct vulnerability URLs for GHSA', async () => { + const mockResolved = new Map([ + [ + 'pkg@1.0.0', + { + name: 'pkg', + version: '1.0.0', + size: 1000, + optional: false, + depth: 'root' as const, + path: ['pkg@1.0.0'], + }, + ], + ]) + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) + + vi.mocked($fetch).mockResolvedValue({ + vulns: [ + { + id: 'GHSA-xxxx-yyyy-zzzz', + summary: 'Test vuln', + database_specific: { severity: 'HIGH' }, + }, + ], + }) + + const result = await analyzeVulnerabilityTree('pkg', '1.0.0') + + expect(result.vulnerablePackages[0].vulnerabilities[0].url).toBe( + 'https://github.com/advisories/GHSA-xxxx-yyyy-zzzz', + ) + }) + + it('generates correct vulnerability URLs for CVE aliases', async () => { + const mockResolved = new Map([ + [ + 'pkg@1.0.0', + { + name: 'pkg', + version: '1.0.0', + size: 1000, + optional: false, + depth: 'root' as const, + path: ['pkg@1.0.0'], + }, + ], + ]) + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) + + vi.mocked($fetch).mockResolvedValue({ + vulns: [ + { + id: 'OSV-2024-001', + summary: 'Test vuln', + aliases: ['CVE-2024-12345'], + database_specific: { severity: 'HIGH' }, + }, + ], + }) + + const result = await analyzeVulnerabilityTree('pkg', '1.0.0') + + expect(result.vulnerablePackages[0].vulnerabilities[0].url).toBe( + 'https://nvd.nist.gov/vuln/detail/CVE-2024-12345', + ) + }) + + it('falls back to OSV URL for other vulnerability IDs', async () => { + const mockResolved = new Map([ + [ + 'pkg@1.0.0', + { + name: 'pkg', + version: '1.0.0', + size: 1000, + optional: false, + depth: 'root' as const, + path: ['pkg@1.0.0'], + }, + ], + ]) + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) + + vi.mocked($fetch).mockResolvedValue({ + vulns: [ + { id: 'PYSEC-2024-001', summary: 'Test vuln', database_specific: { severity: 'HIGH' } }, + ], + }) + + const result = await analyzeVulnerabilityTree('pkg', '1.0.0') + + expect(result.vulnerablePackages[0].vulnerabilities[0].url).toBe( + 'https://osv.dev/vulnerability/PYSEC-2024-001', + ) + }) + + it('extracts severity from CVSS score when database_specific is missing', async () => { + const mockResolved = new Map([ + [ + 'pkg@1.0.0', + { + name: 'pkg', + version: '1.0.0', + size: 1000, + optional: false, + depth: 'root' as const, + path: ['pkg@1.0.0'], + }, + ], + ]) + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) + + vi.mocked($fetch).mockResolvedValue({ + vulns: [ + { + id: 'GHSA-1', + summary: 'Critical (9.5)', + severity: [{ score: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H/9.5' }], + }, + { id: 'GHSA-2', summary: 'High (7.5)', severity: [{ score: '7.5' }] }, + { id: 'GHSA-3', summary: 'Moderate (5.0)', severity: [{ score: '5.0' }] }, + { id: 'GHSA-4', summary: 'Low (2.0)', severity: [{ score: '2.0' }] }, + ], + }) + + const result = await analyzeVulnerabilityTree('pkg', '1.0.0') + + expect(result.totalCounts.critical).toBe(1) + expect(result.totalCounts.high).toBe(1) + expect(result.totalCounts.moderate).toBe(1) + expect(result.totalCounts.low).toBe(1) + }) + }) +})
- {{ vuln.summary }} -