[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: show vulnerability warnings for direct dependencies (#167)

authored by

Florian Heuberger and committed by
GitHub
154d47b4 37afb2d1

+1721 -463
+124
app/components/DependencyPathPopup.vue
··· 1 + <script setup lang="ts"> 2 + defineProps<{ 3 + /** Dependency path from root to vulnerable package (readonly from VulnerabilityTreeResult) */ 4 + path: readonly string[] 5 + }>() 6 + 7 + const { t } = useI18n() 8 + 9 + const isOpen = shallowRef(false) 10 + const popupEl = ref<HTMLElement | null>(null) 11 + const popupPosition = shallowRef<{ top: number; left: number } | null>(null) 12 + 13 + // Function ref - captures the element when popup mounts 14 + function setPopupRef(el: unknown) { 15 + popupEl.value = (el as HTMLElement) || null 16 + } 17 + 18 + function closePopup() { 19 + isOpen.value = false 20 + } 21 + 22 + // Close popup on click outside 23 + onClickOutside(popupEl, () => { 24 + if (isOpen.value) closePopup() 25 + }) 26 + 27 + // Close popup on ESC or scroll 28 + function handleKeydown(e: KeyboardEvent) { 29 + if (e.key === 'Escape') closePopup() 30 + } 31 + 32 + onMounted(() => { 33 + document.addEventListener('keydown', handleKeydown) 34 + window.addEventListener('scroll', closePopup, true) 35 + }) 36 + 37 + onUnmounted(() => { 38 + document.removeEventListener('keydown', handleKeydown) 39 + window.removeEventListener('scroll', closePopup, true) 40 + }) 41 + 42 + function togglePopup(event: MouseEvent) { 43 + if (isOpen.value) { 44 + closePopup() 45 + } else { 46 + const button = event.currentTarget as HTMLElement 47 + const rect = button.getBoundingClientRect() 48 + popupPosition.value = { 49 + top: rect.bottom + 4, 50 + left: rect.left, 51 + } 52 + isOpen.value = true 53 + } 54 + } 55 + 56 + function getPopupStyle(): Record<string, string> { 57 + if (!popupPosition.value) return {} 58 + return { 59 + top: `${popupPosition.value.top}px`, 60 + left: `${popupPosition.value.left}px`, 61 + } 62 + } 63 + 64 + // Parse package string "name@version" into { name, version } 65 + function parsePackageString(pkg: string): { name: string; version: string } { 66 + const atIndex = pkg.lastIndexOf('@') 67 + if (atIndex > 0) { 68 + return { name: pkg.slice(0, atIndex), version: pkg.slice(atIndex + 1) } 69 + } 70 + return { name: pkg, version: '' } 71 + } 72 + </script> 73 + 74 + <template> 75 + <div class="relative"> 76 + <!-- Path badge button --> 77 + <button 78 + type="button" 79 + class="path-badge font-mono text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-400 cursor-pointer transition-all duration-200 ease-out whitespace-nowrap flex items-center gap-1 hover:bg-amber-500/20 hover:border-amber-500/50" 80 + :aria-expanded="isOpen" 81 + @click.stop="togglePopup" 82 + > 83 + <span class="i-carbon-tree-view w-3 h-3" aria-hidden="true" /> 84 + <span>{{ t('package.vulnerabilities.path') }}</span> 85 + </button> 86 + 87 + <!-- Tree popup --> 88 + <div 89 + v-if="isOpen" 90 + :ref="setPopupRef" 91 + class="fixed z-[100] bg-bg-elevated border border-border rounded-lg shadow-xl p-3 min-w-64 max-w-sm" 92 + :style="getPopupStyle()" 93 + > 94 + <ul class="list-none m-0 p-0 space-y-0.5"> 95 + <li 96 + v-for="(pathItem, idx) in path" 97 + :key="idx" 98 + class="font-mono text-xs" 99 + :style="{ paddingLeft: `${idx * 12}px` }" 100 + > 101 + <span v-if="idx > 0" class="text-fg-subtle mr-1">└─</span> 102 + <NuxtLink 103 + :to="{ 104 + name: 'package', 105 + params: { 106 + package: [ 107 + ...parsePackageString(pathItem).name.split('/'), 108 + 'v', 109 + parsePackageString(pathItem).version, 110 + ], 111 + }, 112 + }" 113 + class="hover:underline" 114 + :class="idx === path.length - 1 ? 'text-fg font-medium' : 'text-fg-muted'" 115 + @click="closePopup" 116 + > 117 + {{ pathItem }} 118 + </NuxtLink> 119 + <span v-if="idx === path.length - 1" class="ml-1 text-amber-500">⚠</span> 120 + </li> 121 + </ul> 122 + </div> 123 + </div> 124 + </template>
+32
app/components/PackageDependencies.vue
··· 1 1 <script setup lang="ts"> 2 + import { useVulnerabilityTree } from '~/composables/useVulnerabilityTree' 3 + import { SEVERITY_TEXT_COLORS, getHighestSeverity } from '#shared/utils/severity' 4 + 2 5 const props = defineProps<{ 3 6 packageName: string 7 + version: string 4 8 dependencies?: Record<string, string> 5 9 peerDependencies?: Record<string, string> 6 10 peerDependenciesMeta?: Record<string, { optional?: boolean }> ··· 9 13 10 14 // Fetch outdated info for dependencies 11 15 const outdatedDeps = useOutdatedDependencies(() => props.dependencies) 16 + 17 + // Get vulnerability info from shared cache (already fetched by PackageVulnerabilityTree) 18 + const { data: vulnTree } = useVulnerabilityTree( 19 + () => props.packageName, 20 + () => props.version, 21 + ) 22 + 23 + // Check if a dependency has vulnerabilities (only direct deps) 24 + function getVulnerableDepInfo(depName: string) { 25 + if (!vulnTree.value) return null 26 + return vulnTree.value.vulnerablePackages.find(p => p.name === depName && p.depth === 'direct') 27 + } 12 28 13 29 // Expanded state for each section 14 30 const depsExpanded = shallowRef(false) ··· 92 108 <span class="i-carbon-warning-alt w-3 h-3 block" /> 93 109 </span> 94 110 <NuxtLink 111 + v-if="getVulnerableDepInfo(dep)" 112 + :to="{ 113 + name: 'package', 114 + params: { package: [...dep.split('/'), 'v', getVulnerableDepInfo(dep)!.version] }, 115 + }" 116 + class="shrink-0" 117 + :class="SEVERITY_TEXT_COLORS[getHighestSeverity(getVulnerableDepInfo(dep)!.counts)]" 118 + :title="`${getVulnerableDepInfo(dep)!.counts.total} vulnerabilities`" 119 + > 120 + <span class="i-carbon-security w-3 h-3 block" aria-hidden="true" /> 121 + <span class="sr-only">View vulnerabilities</span> 122 + </NuxtLink> 123 + <NuxtLink 95 124 :to="{ name: 'package', params: { package: [...dep.split('/'), 'v', version] } }" 96 125 class="font-mono text-xs text-right truncate" 97 126 :class="getVersionClass(outdatedDeps[dep])" ··· 101 130 </NuxtLink> 102 131 <span v-if="outdatedDeps[dep]" class="sr-only"> 103 132 ({{ getOutdatedTooltip(outdatedDeps[dep]) }}) 133 + </span> 134 + <span v-if="getVulnerableDepInfo(dep)" class="sr-only"> 135 + ({{ getVulnerableDepInfo(dep)!.counts.total }} vulnerabilities) 104 136 </span> 105 137 </span> 106 138 </li>
-248
app/components/PackageVulnerabilities.vue
··· 1 - <script setup lang="ts"> 2 - import type { 3 - OsvQueryResponse, 4 - OsvVulnerability, 5 - OsvSeverityLevel, 6 - VulnerabilitySummary, 7 - } from '#shared/types' 8 - 9 - const props = defineProps<{ 10 - packageName: string 11 - version: string 12 - }>() 13 - 14 - const { data: vulnData, status } = useLazyAsyncData( 15 - `osv-${props.packageName}@${props.version}`, 16 - async () => { 17 - const response = await $fetch<OsvQueryResponse>('https://api.osv.dev/v1/query', { 18 - method: 'POST', 19 - body: { 20 - package: { 21 - name: props.packageName, 22 - ecosystem: 'npm', 23 - }, 24 - version: props.version, 25 - }, 26 - }) 27 - 28 - const vulns = response.vulns || [] 29 - const vulnerabilities = vulns.map(toVulnerabilitySummary) 30 - 31 - // Sort by severity (critical first) 32 - const severityOrder: Record<OsvSeverityLevel, number> = { 33 - critical: 0, 34 - high: 1, 35 - moderate: 2, 36 - low: 3, 37 - unknown: 4, 38 - } 39 - vulnerabilities.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]) 40 - 41 - // Count by severity 42 - const counts = { total: vulnerabilities.length, critical: 0, high: 0, moderate: 0, low: 0 } 43 - for (const v of vulnerabilities) { 44 - if (v.severity === 'critical') counts.critical++ 45 - else if (v.severity === 'high') counts.high++ 46 - else if (v.severity === 'moderate') counts.moderate++ 47 - else if (v.severity === 'low') counts.low++ 48 - } 49 - 50 - return { vulnerabilities, counts } 51 - }, 52 - { 53 - default: () => ({ 54 - vulnerabilities: [] as VulnerabilitySummary[], 55 - counts: { total: 0, critical: 0, high: 0, moderate: 0, low: 0 }, 56 - }), 57 - }, 58 - ) 59 - 60 - function getSeverityLevel(vuln: OsvVulnerability): OsvSeverityLevel { 61 - const dbSeverity = vuln.database_specific?.severity?.toLowerCase() 62 - if (dbSeverity) { 63 - if (dbSeverity === 'critical') return 'critical' 64 - if (dbSeverity === 'high') return 'high' 65 - if (dbSeverity === 'moderate' || dbSeverity === 'medium') return 'moderate' 66 - if (dbSeverity === 'low') return 'low' 67 - } 68 - 69 - const severityEntry = vuln.severity?.[0] 70 - if (severityEntry?.score) { 71 - const match = severityEntry.score.match(/(?:^|[/:])(\d+(?:\.\d+)?)$/) 72 - if (match?.[1]) { 73 - const score = parseFloat(match[1]) 74 - if (score >= 9.0) return 'critical' 75 - if (score >= 7.0) return 'high' 76 - if (score >= 4.0) return 'moderate' 77 - if (score > 0) return 'low' 78 - } 79 - } 80 - 81 - return 'unknown' 82 - } 83 - 84 - function getVulnerabilityUrl(vuln: OsvVulnerability): string { 85 - if (vuln.id.startsWith('GHSA-')) { 86 - return `https://github.com/advisories/${vuln.id}` 87 - } 88 - const cveAlias = vuln.aliases?.find(a => a.startsWith('CVE-')) 89 - if (cveAlias) { 90 - return `https://nvd.nist.gov/vuln/detail/${cveAlias}` 91 - } 92 - return `https://osv.dev/vulnerability/${vuln.id}` 93 - } 94 - 95 - function toVulnerabilitySummary(vuln: OsvVulnerability): VulnerabilitySummary { 96 - return { 97 - id: vuln.id, 98 - summary: vuln.summary || $t('package.vulnerabilities.no_description'), 99 - severity: getSeverityLevel(vuln), 100 - aliases: vuln.aliases || [], 101 - url: getVulnerabilityUrl(vuln), 102 - } 103 - } 104 - 105 - const hasVulnerabilities = computed(() => vulnData.value.counts.total > 0) 106 - 107 - // Severity color classes for the banner 108 - const severityColors: Record<OsvSeverityLevel, string> = { 109 - critical: 'text-red-400 bg-red-500/10 border-red-500/30', 110 - high: 'text-orange-400 bg-orange-500/10 border-orange-500/30', 111 - moderate: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30', 112 - low: 'text-blue-400 bg-blue-500/10 border-blue-500/30', 113 - unknown: 'text-fg-muted bg-bg-subtle border-border', 114 - } 115 - 116 - // Severity badge styles - greyscale theme matching the design system 117 - const severityBadgeColors: Record<OsvSeverityLevel, string> = { 118 - critical: 'bg-bg-muted border border-border text-fg', 119 - high: 'bg-bg-muted border border-border text-fg-muted', 120 - moderate: 'bg-bg-muted border border-border text-fg-muted', 121 - low: 'bg-bg-muted border border-border text-fg-subtle', 122 - unknown: 'bg-bg-muted border border-border text-fg-subtle', 123 - } 124 - 125 - // Expand/collapse state 126 - const isExpanded = shallowRef(false) 127 - 128 - // Get highest severity for banner color 129 - const highestSeverity = computed<OsvSeverityLevel>(() => { 130 - const counts = vulnData.value.counts 131 - if (counts.critical > 0) return 'critical' 132 - if (counts.high > 0) return 'high' 133 - if (counts.moderate > 0) return 'moderate' 134 - if (counts.low > 0) return 'low' 135 - return 'unknown' 136 - }) 137 - 138 - // Summary text for collapsed view 139 - const summaryText = computed(() => { 140 - const counts = vulnData.value.counts 141 - const parts: string[] = [] 142 - if (counts.critical > 0) 143 - parts.push(`${counts.critical} ${$t('package.vulnerabilities.severity.critical')}`) 144 - if (counts.high > 0) parts.push(`${counts.high} ${$t('package.vulnerabilities.severity.high')}`) 145 - if (counts.moderate > 0) 146 - parts.push(`${counts.moderate} ${$t('package.vulnerabilities.severity.moderate')}`) 147 - if (counts.low > 0) parts.push(`${counts.low} ${$t('package.vulnerabilities.severity.low')}`) 148 - return parts.join(', ') 149 - }) 150 - </script> 151 - 152 - <template> 153 - <div v-if="status === 'success' && hasVulnerabilities" class="mb-6"> 154 - <!-- Collapsible vulnerability banner --> 155 - <div 156 - role="alert" 157 - class="rounded-lg border overflow-hidden" 158 - :class="severityColors[highestSeverity]" 159 - > 160 - <!-- Header (always visible, clickable to expand) --> 161 - <button 162 - type="button" 163 - class="w-full flex items-center justify-between gap-3 px-4 py-3 text-left transition-colors duration-200 hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-fg/50" 164 - :aria-expanded="isExpanded" 165 - aria-controls="vulnerability-details" 166 - @click="isExpanded = !isExpanded" 167 - > 168 - <div class="flex items-center gap-2 min-w-0"> 169 - <span class="i-carbon-warning-alt w-4 h-4 shrink-0" aria-hidden="true" /> 170 - <span class="font-mono text-sm font-medium truncate"> 171 - {{ 172 - $t( 173 - 'package.vulnerabilities.found', 174 - { count: vulnData.counts.total }, 175 - vulnData.counts.total, 176 - ) 177 - }} 178 - </span> 179 - </div> 180 - <div class="flex items-center gap-2 shrink-0"> 181 - <span class="text-xs opacity-80 hidden sm:inline">{{ summaryText }}</span> 182 - <span 183 - class="i-carbon-chevron-down w-4 h-4 transition-transform duration-200" 184 - :class="{ 'rotate-180': isExpanded }" 185 - aria-hidden="true" 186 - /> 187 - </div> 188 - </button> 189 - 190 - <!-- Expandable details - neutral background for better contrast --> 191 - <div 192 - v-show="isExpanded" 193 - id="vulnerability-details" 194 - class="border-t border-border bg-bg-subtle" 195 - > 196 - <ul class="divide-y divide-border list-none m-0 p-0"> 197 - <li 198 - v-for="vuln in vulnData.vulnerabilities" 199 - :key="vuln.id" 200 - class="px-4 py-3 hover:bg-bg-muted transition-colors duration-200" 201 - > 202 - <div class="flex items-start justify-between gap-3"> 203 - <div class="min-w-0 flex-1"> 204 - <div class="flex items-center gap-2 mb-1"> 205 - <a 206 - :href="vuln.url" 207 - target="_blank" 208 - rel="noopener noreferrer" 209 - class="font-mono text-sm font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded" 210 - > 211 - {{ vuln.id }} 212 - </a> 213 - <span 214 - class="px-2 py-0.5 text-xs font-mono rounded" 215 - :class="severityBadgeColors[vuln.severity]" 216 - > 217 - {{ vuln.severity }} 218 - </span> 219 - </div> 220 - <p class="text-sm text-fg-muted line-clamp-2 m-0"> 221 - {{ vuln.summary }} 222 - </p> 223 - <div v-if="vuln.aliases.length > 0" class="mt-1"> 224 - <span 225 - v-for="alias in vuln.aliases.slice(0, 2)" 226 - :key="alias" 227 - class="text-xs text-fg-subtle mr-2" 228 - > 229 - {{ alias }} 230 - </span> 231 - </div> 232 - </div> 233 - <a 234 - :href="vuln.url" 235 - target="_blank" 236 - rel="noopener noreferrer" 237 - class="shrink-0 p-1.5 text-fg-subtle hover:text-fg transition-colors duration-200 rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50" 238 - :aria-label="$t('package.vulnerabilities.view_details')" 239 - > 240 - <span class="i-carbon-launch w-3.5 h-3.5" aria-hidden="true" /> 241 - </a> 242 - </div> 243 - </li> 244 - </ul> 245 - </div> 246 - </div> 247 - </div> 248 - </template>
+243
app/components/PackageVulnerabilityTree.vue
··· 1 + <script setup lang="ts"> 2 + import { SEVERITY_LEVELS } from '#shared/types' 3 + import { SEVERITY_COLORS } from '#shared/utils/severity' 4 + 5 + const props = defineProps<{ 6 + packageName: string 7 + version: string 8 + }>() 9 + 10 + const { t } = useI18n() 11 + 12 + const { 13 + data: vulnTree, 14 + status, 15 + fetch: fetchVulnTree, 16 + } = useVulnerabilityTree( 17 + () => props.packageName, 18 + () => props.version, 19 + ) 20 + 21 + onMounted(() => fetchVulnTree()) 22 + 23 + const isExpanded = shallowRef(false) 24 + const showAllPackages = shallowRef(false) 25 + 26 + const hasVulnerabilities = computed( 27 + () => vulnTree.value && vulnTree.value.vulnerablePackages.length > 0, 28 + ) 29 + 30 + // Banner - amber for better light mode contrast 31 + const bannerColor = 'border-amber-600/40 bg-amber-500/10 text-amber-700 dark:text-amber-400' 32 + 33 + const summaryText = computed(() => { 34 + if (!vulnTree.value) return '' 35 + const { totalCounts } = vulnTree.value 36 + return SEVERITY_LEVELS.filter(s => totalCounts[s] > 0) 37 + .map(s => `${totalCounts[s]} ${t(`package.vulnerabilities.severity.${s}`)}`) 38 + .join(', ') 39 + }) 40 + 41 + // Styling for each depth level - using accessible colors for both themes 42 + const depthStyles = { 43 + root: { 44 + bg: 'bg-amber-500/5 border-l-2 border-l-amber-600', 45 + text: 'text-fg', 46 + }, 47 + direct: { 48 + bg: 'bg-amber-500/5 border-l-2 border-l-amber-500', 49 + text: 'text-fg-muted', 50 + }, 51 + transitive: { 52 + bg: 'bg-amber-500/5 border-l-2 border-l-amber-400', 53 + text: 'text-fg-muted', 54 + }, 55 + } as const 56 + 57 + // Helper to get depth style with fallback 58 + function getDepthStyle(depth: string | undefined) { 59 + if (depth && depth in depthStyles) { 60 + return depthStyles[depth as keyof typeof depthStyles] 61 + } 62 + return depthStyles.transitive 63 + } 64 + </script> 65 + 66 + <template> 67 + <section 68 + v-if="status === 'success' && hasVulnerabilities" 69 + aria-labelledby="vuln-tree-heading" 70 + class="relative" 71 + > 72 + <!-- Collapsible vulnerability banner --> 73 + <div role="alert" class="rounded-lg border overflow-hidden" :class="bannerColor"> 74 + <!-- Header --> 75 + <button 76 + type="button" 77 + class="w-full flex items-center justify-between gap-3 px-4 py-3 text-left transition-colors duration-200 hover:bg-white/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-fg/50" 78 + :aria-expanded="isExpanded" 79 + aria-controls="vuln-tree-details" 80 + @click="isExpanded = !isExpanded" 81 + > 82 + <div class="flex items-center gap-2 min-w-0"> 83 + <span class="i-carbon-warning-alt w-4 h-4 shrink-0" aria-hidden="true" /> 84 + <span class="font-mono text-sm font-medium truncate"> 85 + {{ 86 + t( 87 + 'package.vulnerabilities.tree_found', 88 + { 89 + vulns: vulnTree!.totalCounts.total, 90 + packages: vulnTree!.vulnerablePackages.length, 91 + total: vulnTree!.totalPackages, 92 + }, 93 + vulnTree!.totalCounts.total, 94 + ) 95 + }} 96 + </span> 97 + </div> 98 + <div class="flex items-center gap-2 shrink-0"> 99 + <span class="text-xs opacity-80 hidden sm:inline">{{ summaryText }}</span> 100 + <span 101 + class="i-carbon-chevron-down w-4 h-4 transition-transform duration-200" 102 + :class="{ 'rotate-180': isExpanded }" 103 + aria-hidden="true" 104 + /> 105 + </div> 106 + </button> 107 + 108 + <!-- Expandable details --> 109 + <div v-show="isExpanded" id="vuln-tree-details" class="border-t border-border bg-bg-subtle"> 110 + <ul class="divide-y divide-border list-none m-0 p-0"> 111 + <li 112 + v-for="pkg in vulnTree!.vulnerablePackages.slice(0, showAllPackages ? undefined : 5)" 113 + :key="`${pkg.name}@${pkg.version}`" 114 + class="px-4 py-3" 115 + :class="getDepthStyle(pkg.depth).bg" 116 + > 117 + <div class="flex items-center justify-between gap-2 mb-2"> 118 + <div class="flex items-center gap-2 min-w-0 relative"> 119 + <!-- Path badge - click to show tree popup --> 120 + <DependencyPathPopup v-if="pkg.path && pkg.path.length > 1" :path="pkg.path" /> 121 + 122 + <NuxtLink 123 + :to="{ 124 + name: 'package', 125 + params: { package: [...pkg.name.split('/'), 'v', pkg.version] }, 126 + }" 127 + class="font-mono text-sm font-medium hover:underline truncate shrink min-w-0" 128 + :class="getDepthStyle(pkg.depth).text" 129 + > 130 + {{ pkg.name }}@{{ pkg.version }} 131 + </NuxtLink> 132 + </div> 133 + <div class="flex items-center gap-1 shrink-0"> 134 + <span 135 + v-for="s in SEVERITY_LEVELS.filter(s => pkg.counts[s] > 0)" 136 + :key="s" 137 + class="px-1.5 py-0.5 text-[10px] font-mono rounded border" 138 + :class="SEVERITY_COLORS[s]" 139 + > 140 + {{ pkg.counts[s] }} {{ t(`package.vulnerabilities.severity.${s}`) }} 141 + </span> 142 + </div> 143 + </div> 144 + <!-- Show first 2 vulnerabilities --> 145 + <ul class="space-y-1 list-none m-0 p-0"> 146 + <li 147 + v-for="vuln in pkg.vulnerabilities.slice(0, 2)" 148 + :key="vuln.id" 149 + class="flex items-center gap-2 text-xs text-fg-muted" 150 + > 151 + <a 152 + :href="vuln.url" 153 + target="_blank" 154 + rel="noopener noreferrer" 155 + class="font-mono hover:underline shrink-0" 156 + > 157 + {{ vuln.id }} 158 + </a> 159 + <span class="truncate">{{ vuln.summary }}</span> 160 + </li> 161 + <li v-if="pkg.vulnerabilities.length > 2" class="text-xs text-fg-subtle"> 162 + {{ t('package.vulnerabilities.more', { count: pkg.vulnerabilities.length - 2 }) }} 163 + </li> 164 + </ul> 165 + </li> 166 + </ul> 167 + 168 + <button 169 + v-if="vulnTree!.vulnerablePackages.length > 5 && !showAllPackages" 170 + type="button" 171 + class="w-full px-4 py-2 text-xs font-mono text-fg-muted hover:text-fg border-t border-border transition-colors duration-200" 172 + @click="showAllPackages = true" 173 + > 174 + {{ 175 + t('package.vulnerabilities.show_all_packages', { 176 + count: vulnTree!.vulnerablePackages.length, 177 + }) 178 + }} 179 + </button> 180 + 181 + <!-- Warning if some queries failed --> 182 + <div 183 + v-if="vulnTree!.failedQueries" 184 + class="px-4 py-2 text-xs text-fg-subtle border-t border-border flex items-center gap-2" 185 + > 186 + <span class="i-carbon-warning w-3 h-3" aria-hidden="true" /> 187 + <span>{{ t('package.vulnerabilities.packages_failed', vulnTree!.failedQueries) }}</span> 188 + </div> 189 + </div> 190 + </div> 191 + </section> 192 + 193 + <!-- Loading state - muted --> 194 + <section 195 + v-else-if="status === 'pending' || status === 'idle'" 196 + aria-labelledby="vuln-tree-loading" 197 + > 198 + <div class="rounded-lg border border-border bg-bg-subtle px-4 py-3"> 199 + <div class="flex items-center gap-2"> 200 + <span 201 + class="i-carbon-circle-dash w-4 h-4 animate-spin motion-reduce:animate-none text-fg-subtle" 202 + aria-hidden="true" 203 + /> 204 + <span class="text-sm text-fg-muted">{{ t('package.vulnerabilities.scanning_tree') }}</span> 205 + </div> 206 + </div> 207 + </section> 208 + 209 + <!-- No vulnerabilities found - muted, not attention-grabbing --> 210 + <section 211 + v-else-if="status === 'success' && !hasVulnerabilities" 212 + aria-labelledby="vuln-tree-success" 213 + > 214 + <div class="rounded-lg border border-border bg-bg-subtle px-4 py-3"> 215 + <div class="flex items-center gap-2"> 216 + <span class="i-carbon-checkmark w-4 h-4 text-fg-subtle" aria-hidden="true" /> 217 + <span class="text-sm text-fg-muted"> 218 + {{ t('package.vulnerabilities.no_known', { count: vulnTree?.totalPackages ?? 0 }) }} 219 + </span> 220 + </div> 221 + <!-- Warning if some queries failed --> 222 + <div 223 + v-if="vulnTree?.failedQueries" 224 + class="flex items-center gap-2 mt-2 text-xs text-fg-subtle" 225 + > 226 + <span class="i-carbon-warning w-3 h-3" aria-hidden="true" /> 227 + <span>{{ t('package.vulnerabilities.packages_failed', vulnTree.failedQueries) }}</span> 228 + </div> 229 + </div> 230 + </section> 231 + 232 + <!-- Error state - subtle, not alarming --> 233 + <section v-else-if="status === 'error'" aria-labelledby="vuln-tree-error"> 234 + <div class="rounded-lg border border-border bg-bg-subtle px-4 py-3"> 235 + <div class="flex items-center gap-2"> 236 + <span class="i-carbon-warning w-4 h-4 text-fg-subtle" aria-hidden="true" /> 237 + <span class="text-sm text-fg-muted"> 238 + {{ t('package.vulnerabilities.scan_failed') }} 239 + </span> 240 + </div> 241 + </div> 242 + </section> 243 + </template>
+2 -2
app/composables/useNpmRegistry.ts
··· 469 469 * Check if a version constraint explicitly includes a prerelease tag. 470 470 * e.g., "^1.0.0-alpha" or ">=2.0.0-beta.1" include prereleases 471 471 */ 472 - function constraintIncludesPrerelease(constraint: string): boolean { 472 + export function constraintIncludesPrerelease(constraint: string): boolean { 473 473 return ( 474 474 /-(alpha|beta|rc|next|canary|dev|preview|pre|experimental)/i.test(constraint) || 475 475 /-\d/.test(constraint) ··· 479 479 /** 480 480 * Check if a constraint is a non-semver value (git URL, file path, etc.) 481 481 */ 482 - function isNonSemverConstraint(constraint: string): boolean { 482 + export function isNonSemverConstraint(constraint: string): boolean { 483 483 return ( 484 484 constraint.startsWith('git') || 485 485 constraint.startsWith('http') ||
+51
app/composables/useVulnerabilityTree.ts
··· 1 + import type { VulnerabilityTreeResult } from '#shared/types/osv' 2 + 3 + /** 4 + * Shared composable for vulnerability tree data. 5 + * Fetches once and caches the result so multiple components can use it. 6 + */ 7 + export function useVulnerabilityTree( 8 + packageName: MaybeRefOrGetter<string>, 9 + version: MaybeRefOrGetter<string>, 10 + ) { 11 + // Build a stable key from the current values 12 + const name = toValue(packageName) 13 + const ver = toValue(version) 14 + const key = `vuln-tree:v1:${name}@${ver}` 15 + 16 + // Use useState for SSR-safe caching across components 17 + const data = useState<VulnerabilityTreeResult | null>(key, () => null) 18 + const status = useState<'idle' | 'pending' | 'success' | 'error'>(`${key}:status`, () => 'idle') 19 + const error = useState<Error | null>(`${key}:error`, () => null) 20 + 21 + async function fetch() { 22 + const pkgName = toValue(packageName) 23 + const pkgVersion = toValue(version) 24 + 25 + if (!pkgName || !pkgVersion) return 26 + 27 + // Already fetched or fetching 28 + if (status.value === 'success' || status.value === 'pending') return 29 + 30 + status.value = 'pending' 31 + error.value = null 32 + 33 + try { 34 + const result = await $fetch<VulnerabilityTreeResult>( 35 + `/api/registry/vulnerabilities/${encodePackageName(pkgName)}/v/${pkgVersion}`, 36 + ) 37 + data.value = result 38 + status.value = 'success' 39 + } catch (e) { 40 + error.value = e instanceof Error ? e : new Error('Failed to fetch vulnerabilities') 41 + status.value = 'error' 42 + } 43 + } 44 + 45 + return { 46 + data: readonly(data), 47 + status: readonly(status), 48 + error: readonly(error), 49 + fetch, 50 + } 51 + }
+18 -6
app/pages/[...package].vue
··· 811 811 /> 812 812 813 813 <!-- Binary-only packages: Show only execute command (no install) --> 814 - <section v-if="isBinaryOnly" aria-labelledby="run-heading" class="area-install"> 814 + <section v-if="isBinaryOnly" aria-labelledby="run-heading" class="area-install scroll-mt-20"> 815 815 <div class="flex flex-wrap items-center justify-between mb-3"> 816 816 <h2 id="run-heading" class="text-xs text-fg-subtle uppercase tracking-wider">Run</h2> 817 817 <!-- Package manager tabs --> ··· 1071 1071 </div> 1072 1072 </section> 1073 1073 1074 + <!-- Vulnerability scan - full width --> 1075 + <div class="area-vulns"> 1076 + <ClientOnly> 1077 + <PackageVulnerabilityTree 1078 + v-if="displayVersion" 1079 + :package-name="pkg.name" 1080 + :version="displayVersion.version" 1081 + /> 1082 + </ClientOnly> 1083 + </div> 1084 + 1074 1085 <!-- README --> 1075 1086 <section 1076 1087 id="readme" ··· 1217 1228 1218 1229 <!-- Dependencies --> 1219 1230 <PackageDependencies 1220 - v-if="hasDependencies" 1231 + v-if="hasDependencies && displayVersion" 1221 1232 :package-name="pkg.name" 1222 - :dependencies="displayVersion?.dependencies" 1223 - :peer-dependencies="displayVersion?.peerDependencies" 1224 - :peer-dependencies-meta="displayVersion?.peerDependenciesMeta" 1225 - :optional-dependencies="displayVersion?.optionalDependencies" 1233 + :version="displayVersion.version" 1234 + :dependencies="displayVersion.dependencies" 1235 + :peer-dependencies="displayVersion.peerDependencies" 1236 + :peer-dependencies-meta="displayVersion.peerDependenciesMeta" 1237 + :optional-dependencies="displayVersion.optionalDependencies" 1226 1238 /> 1227 1239 </aside> 1228 1240 </div>
+15
i18n/locales/en.json
··· 218 218 "vulnerabilities": { 219 219 "no_description": "No description available", 220 220 "found": "{count} vulnerability found | {count} vulnerabilities found", 221 + "deps_found": "{count} vulnerability found | {count} vulnerabilities found", 222 + "deps_affected": "{count} dependency affected | {count} dependencies affected", 223 + "tree_found": "{vulns} vulnerability in {packages}/{total} packages | {vulns} vulnerabilities in {packages}/{total} packages", 224 + "scanning_tree": "Scanning dependency tree...", 225 + "show_all_packages": "show all {count} affected packages", 221 226 "no_summary": "No summary", 222 227 "view_details": "View vulnerability details", 228 + "path": "path", 229 + "more": "+{count} more", 230 + "packages_failed": "{count} package could not be checked | {count} packages could not be checked", 231 + "no_known": "No known vulnerabilities in {count} packages", 232 + "scan_failed": "Could not scan for vulnerabilities", 233 + "depth": { 234 + "root": "This package", 235 + "direct": "Direct dependency", 236 + "transitive": "Transitive dependency (indirect)" 237 + }, 223 238 "severity": { 224 239 "critical": "critical", 225 240 "high": "high",
+15
i18n/locales/fr.json
··· 208 208 "vulnerabilities": { 209 209 "no_description": "Aucune description disponible", 210 210 "found": "{count} vulnérabilité trouvée | {count} vulnérabilités trouvées", 211 + "deps_found": "{count} vulnérabilité trouvée | {count} vulnérabilités trouvées", 212 + "deps_affected": "{count} dépendance affectée | {count} dépendances affectées", 213 + "tree_found": "{vulns} vulnérabilité dans {packages}/{total} paquets | {vulns} vulnérabilités dans {packages}/{total} paquets", 214 + "scanning_tree": "Analyse de l'arbre des dépendances...", 215 + "show_all_packages": "afficher les {count} paquets affectés", 211 216 "no_summary": "Aucun résumé", 212 217 "view_details": "Voir les détails de la vulnérabilité", 218 + "path": "chemin", 219 + "more": "+{count} de plus", 220 + "packages_failed": "{count} paquet n'a pas pu être vérifié | {count} paquets n'ont pas pu être vérifiés", 221 + "no_known": "Aucune vulnérabilité connue dans {count} paquets", 222 + "scan_failed": "Impossible d'analyser les vulnérabilités", 223 + "depth": { 224 + "root": "Ce paquet", 225 + "direct": "Dépendance directe", 226 + "transitive": "Dépendance transitive (indirecte)" 227 + }, 213 228 "severity": { 214 229 "critical": "critique", 215 230 "high": "élevée",
+16 -1
i18n/locales/it.json
··· 217 217 }, 218 218 "vulnerabilities": { 219 219 "no_description": "Nessuna descrizione disponibile", 220 - "found": "{count} vulnerabilità trovate", 220 + "found": "{count} vulnerabilità trovata | {count} vulnerabilità trovate", 221 + "deps_found": "{count} vulnerabilità trovata | {count} vulnerabilità trovate", 222 + "deps_affected": "{count} dipendenza interessata | {count} dipendenze interessate", 223 + "tree_found": "{vulns} vulnerabilità in {packages}/{total} pacchetti | {vulns} vulnerabilità in {packages}/{total} pacchetti", 224 + "scanning_tree": "Scansione dell'albero delle dipendenze...", 225 + "show_all_packages": "mostra tutti i {count} pacchetti interessati", 221 226 "no_summary": "Nessun riassunto", 222 227 "view_details": "Vedi dettagli sulle vulnerabilitá", 228 + "path": "percorso", 229 + "more": "+{count} altri", 230 + "packages_failed": "{count} pacchetto non ha potuto essere verificato | {count} pacchetti non hanno potuto essere verificati", 231 + "no_known": "Nessuna vulnerabilità nota in {count} pacchetti", 232 + "scan_failed": "Impossibile analizzare le vulnerabilità", 233 + "depth": { 234 + "root": "Questo pacchetto", 235 + "direct": "Dipendenza diretta", 236 + "transitive": "Dipendenza transitiva (indiretta)" 237 + }, 223 238 "severity": { 224 239 "critical": "critica", 225 240 "high": "alta",
+15
i18n/locales/zh-CN.json
··· 218 218 "vulnerabilities": { 219 219 "no_description": "没有可用的描述", 220 220 "found": "{count} 个漏洞", 221 + "deps_found": "{count} 个漏洞", 222 + "deps_affected": "{count} 个受影响的依赖", 223 + "tree_found": "在 {packages}/{total} 个包中发现 {vulns} 个漏洞", 224 + "scanning_tree": "正在扫描依赖树...", 225 + "show_all_packages": "显示全部 {count} 个受影响的包", 221 226 "no_summary": "没有总结", 222 227 "view_details": "查看漏洞详情", 228 + "path": "路径", 229 + "more": "+{count} 更多", 230 + "packages_failed": "{count} 个包无法检查", 231 + "no_known": "在 {count} 个包中未发现已知漏洞", 232 + "scan_failed": "无法扫描漏洞", 233 + "depth": { 234 + "root": "此包", 235 + "direct": "直接依赖", 236 + "transitive": "间接依赖(传递性)" 237 + }, 223 238 "severity": { 224 239 "critical": "严重", 225 240 "high": "高",
+50
server/api/registry/vulnerabilities/[...pkg].get.ts
··· 1 + import * as v from 'valibot' 2 + import { PackageRouteParamsSchema } from '#shared/schemas/package' 3 + import { CACHE_MAX_AGE_ONE_HOUR } from '#shared/utils/constants' 4 + 5 + /** 6 + * GET /api/registry/vulnerabilities/:name or /api/registry/vulnerabilities/:name/v/:version 7 + * 8 + * Analyze entire dependency tree for vulnerabilities. 9 + */ 10 + export default defineCachedEventHandler( 11 + async event => { 12 + const pkgParamSegments = getRouterParam(event, 'pkg')?.split('/') ?? [] 13 + const { rawPackageName, rawVersion } = parsePackageParams(pkgParamSegments) 14 + 15 + try { 16 + const { packageName, version: requestedVersion } = v.parse(PackageRouteParamsSchema, { 17 + packageName: rawPackageName, 18 + version: rawVersion, 19 + }) 20 + 21 + // If no version specified, resolve to latest 22 + let version = requestedVersion 23 + if (!version) { 24 + const packument = await fetchNpmPackage(packageName) 25 + version = packument['dist-tags']?.latest 26 + if (!version) { 27 + throw createError({ 28 + statusCode: 404, 29 + message: 'No latest version found', 30 + }) 31 + } 32 + } 33 + 34 + return await analyzeVulnerabilityTree(packageName, version) 35 + } catch (error: unknown) { 36 + handleApiError(error, { 37 + statusCode: 502, 38 + message: 'Failed to analyze vulnerabilities', 39 + }) 40 + } 41 + }, 42 + { 43 + maxAge: CACHE_MAX_AGE_ONE_HOUR, 44 + swr: true, 45 + getKey: event => { 46 + const pkg = getRouterParam(event, 'pkg') ?? '' 47 + return `vulnerabilities:v1:${pkg.replace(/\/+$/, '').trim()}` 48 + }, 49 + }, 50 + )
+203
server/utils/dependency-resolver.ts
··· 1 + import type { Packument, PackumentVersion, DependencyDepth } from '#shared/types' 2 + import { maxSatisfying } from 'semver' 3 + 4 + /** 5 + * Target platform for dependency resolution. 6 + * We resolve for linux-x64 with glibc as a representative platform. 7 + */ 8 + export const TARGET_PLATFORM = { 9 + os: 'linux', 10 + cpu: 'x64', 11 + libc: 'glibc', 12 + } 13 + 14 + /** 15 + * Fetch packument with caching (returns null on error for tree traversal) 16 + */ 17 + export const fetchPackument = defineCachedFunction( 18 + async (name: string): Promise<Packument | null> => { 19 + try { 20 + const encodedName = name.startsWith('@') 21 + ? `@${encodeURIComponent(name.slice(1))}` 22 + : encodeURIComponent(name) 23 + 24 + return await $fetch<Packument>(`https://registry.npmjs.org/${encodedName}`) 25 + } catch (error) { 26 + // oxlint-disable-next-line no-console -- log npm registry failures for debugging 27 + if (import.meta.dev) { 28 + console.warn(`[dep-resolver] Failed to fetch packument for ${name}:`, error) 29 + } 30 + return null 31 + } 32 + }, 33 + { 34 + maxAge: 60 * 60, 35 + swr: true, 36 + name: 'packument', 37 + getKey: (name: string) => name, 38 + }, 39 + ) 40 + 41 + /** 42 + * Check if a package version matches the target platform. 43 + * Returns false if the package explicitly excludes our target platform. 44 + */ 45 + export function matchesPlatform(version: PackumentVersion): boolean { 46 + if (version.os && Array.isArray(version.os) && version.os.length > 0) { 47 + const osMatch = version.os.some(os => { 48 + if (os.startsWith('!')) return os.slice(1) !== TARGET_PLATFORM.os 49 + return os === TARGET_PLATFORM.os 50 + }) 51 + if (!osMatch) return false 52 + } 53 + 54 + if (version.cpu && Array.isArray(version.cpu) && version.cpu.length > 0) { 55 + const cpuMatch = version.cpu.some(cpu => { 56 + if (cpu.startsWith('!')) return cpu.slice(1) !== TARGET_PLATFORM.cpu 57 + return cpu === TARGET_PLATFORM.cpu 58 + }) 59 + if (!cpuMatch) return false 60 + } 61 + 62 + const libc = (version as { libc?: string[] }).libc 63 + if (libc && Array.isArray(libc) && libc.length > 0) { 64 + const libcMatch = libc.some(l => { 65 + if (l.startsWith('!')) return l.slice(1) !== TARGET_PLATFORM.libc 66 + return l === TARGET_PLATFORM.libc 67 + }) 68 + if (!libcMatch) return false 69 + } 70 + 71 + return true 72 + } 73 + 74 + /** 75 + * Resolve a semver range to a specific version from available versions. 76 + */ 77 + export function resolveVersion(range: string, versions: string[]): string | null { 78 + if (versions.includes(range)) return range 79 + 80 + // Handle npm: protocol (aliases) 81 + if (range.startsWith('npm:')) { 82 + const atIndex = range.lastIndexOf('@') 83 + if (atIndex > 4) { 84 + return resolveVersion(range.slice(atIndex + 1), versions) 85 + } 86 + return null 87 + } 88 + 89 + // Handle URLs, git refs, etc. - we can't resolve these 90 + if ( 91 + range.startsWith('http://') || 92 + range.startsWith('https://') || 93 + range.startsWith('git://') || 94 + range.startsWith('git+') || 95 + range.startsWith('file:') || 96 + range.includes('/') 97 + ) { 98 + return null 99 + } 100 + 101 + return maxSatisfying(versions, range) 102 + } 103 + 104 + /** Resolved package info */ 105 + export interface ResolvedPackage { 106 + name: string 107 + version: string 108 + size: number 109 + optional: boolean 110 + /** Depth level (only when trackDepth is enabled) */ 111 + depth?: DependencyDepth 112 + /** Dependency path from root (only when trackDepth is enabled) */ 113 + path?: string[] 114 + } 115 + 116 + /** 117 + * Resolve the entire dependency tree for a package. 118 + * Uses level-by-level BFS to ensure correct depth assignment when trackDepth is enabled. 119 + */ 120 + export async function resolveDependencyTree( 121 + rootName: string, 122 + rootVersion: string, 123 + options: { trackDepth?: boolean } = {}, 124 + ): Promise<Map<string, ResolvedPackage>> { 125 + const resolved = new Map<string, ResolvedPackage>() 126 + const seen = new Set<string>() 127 + 128 + // Process level by level for correct depth tracking 129 + // Each entry includes the path of package names leading to this dependency 130 + let currentLevel = new Map<string, { range: string; optional: boolean; path: string[] }>([ 131 + [rootName, { range: rootVersion, optional: false, path: [] }], 132 + ]) 133 + let level = 0 134 + 135 + while (currentLevel.size > 0) { 136 + const nextLevel = new Map<string, { range: string; optional: boolean; path: string[] }>() 137 + 138 + // Mark all packages in current level as seen before processing 139 + for (const name of currentLevel.keys()) { 140 + seen.add(name) 141 + } 142 + 143 + // Process current level in batches 144 + const entries = [...currentLevel.entries()] 145 + for (let i = 0; i < entries.length; i += 20) { 146 + const batch = entries.slice(i, i + 20) 147 + 148 + await Promise.all( 149 + batch.map(async ([name, { range, optional, path }]) => { 150 + const packument = await fetchPackument(name) 151 + if (!packument) return 152 + 153 + const versions = Object.keys(packument.versions) 154 + const version = resolveVersion(range, versions) 155 + if (!version) return 156 + 157 + const versionData = packument.versions[version] 158 + if (!versionData) return 159 + 160 + if (!matchesPlatform(versionData)) return 161 + 162 + const size = (versionData.dist as { unpackedSize?: number })?.unpackedSize ?? 0 163 + const key = `${name}@${version}` 164 + 165 + // Build path for this package (path to parent + this package with version) 166 + const currentPath = [...path, `${name}@${version}`] 167 + 168 + if (!resolved.has(key)) { 169 + const pkg: ResolvedPackage = { name, version, size, optional } 170 + if (options.trackDepth) { 171 + pkg.depth = level === 0 ? 'root' : level === 1 ? 'direct' : 'transitive' 172 + pkg.path = currentPath 173 + } 174 + resolved.set(key, pkg) 175 + } 176 + 177 + // Collect dependencies for next level 178 + if (versionData.dependencies) { 179 + for (const [depName, depRange] of Object.entries(versionData.dependencies)) { 180 + if (!seen.has(depName) && !nextLevel.has(depName)) { 181 + nextLevel.set(depName, { range: depRange, optional: false, path: currentPath }) 182 + } 183 + } 184 + } 185 + 186 + // Collect optional dependencies 187 + if (versionData.optionalDependencies) { 188 + for (const [depName, depRange] of Object.entries(versionData.optionalDependencies)) { 189 + if (!seen.has(depName) && !nextLevel.has(depName)) { 190 + nextLevel.set(depName, { range: depRange, optional: true, path: currentPath }) 191 + } 192 + } 193 + } 194 + }), 195 + ) 196 + } 197 + 198 + currentLevel = nextLevel 199 + level++ 200 + } 201 + 202 + return resolved 203 + }
-190
server/utils/install-size.ts
··· 1 - import type { Packument, PackumentVersion } from '#shared/types' 2 - import { maxSatisfying } from 'semver' 3 - 4 1 /** 5 2 * Result of install size calculation 6 3 */ ··· 25 22 size: number 26 23 /** True if this is an optional dependency */ 27 24 optional?: boolean 28 - } 29 - 30 - /** 31 - * We resolve for linux-x64 with glibc 32 - */ 33 - const TARGET_PLATFORM = { 34 - os: 'linux', 35 - cpu: 'x64', 36 - libc: 'glibc', 37 - } 38 - 39 - const fetchPackument = defineCachedFunction( 40 - async (name: string): Promise<Packument | null> => { 41 - try { 42 - const encodedName = name.startsWith('@') 43 - ? `@${encodeURIComponent(name.slice(1))}` 44 - : encodeURIComponent(name) 45 - 46 - return await $fetch<Packument>(`https://registry.npmjs.org/${encodedName}`) 47 - } catch { 48 - return null 49 - } 50 - }, 51 - { 52 - maxAge: 60 * 60, // 1 hour 53 - swr: true, 54 - name: 'packument', 55 - getKey: (name: string) => name, 56 - }, 57 - ) 58 - 59 - /** 60 - * Check if a package version matches the target platform. 61 - * Returns false if the package explicitly excludes our target platform. 62 - */ 63 - function matchesPlatform(version: PackumentVersion): boolean { 64 - // Check OS compatibility 65 - if (version.os && Array.isArray(version.os) && version.os.length > 0) { 66 - const osMatch = version.os.some(os => { 67 - if (os.startsWith('!')) { 68 - return os.slice(1) !== TARGET_PLATFORM.os 69 - } 70 - return os === TARGET_PLATFORM.os 71 - }) 72 - if (!osMatch) return false 73 - } 74 - 75 - // Check CPU compatibility 76 - if (version.cpu && Array.isArray(version.cpu) && version.cpu.length > 0) { 77 - const cpuMatch = version.cpu.some(cpu => { 78 - if (cpu.startsWith('!')) { 79 - return cpu.slice(1) !== TARGET_PLATFORM.cpu 80 - } 81 - return cpu === TARGET_PLATFORM.cpu 82 - }) 83 - if (!cpuMatch) return false 84 - } 85 - 86 - // Check libc compatibility (if specified) 87 - const libc = (version as { libc?: string[] }).libc 88 - if (libc && Array.isArray(libc) && libc.length > 0) { 89 - const libcMatch = libc.some(l => { 90 - if (l.startsWith('!')) { 91 - return l.slice(1) !== TARGET_PLATFORM.libc 92 - } 93 - return l === TARGET_PLATFORM.libc 94 - }) 95 - if (!libcMatch) return false 96 - } 97 - 98 - return true 99 - } 100 - 101 - /** 102 - * Resolve a semver range to a specific version from available versions. 103 - */ 104 - function resolveVersion(range: string, versions: string[]): string | null { 105 - // Handle exact versions, tags, URLs, etc. 106 - if (versions.includes(range)) { 107 - return range 108 - } 109 - 110 - // Handle npm: protocol (aliases) 111 - if (range.startsWith('npm:')) { 112 - // npm:package@version - extract the version part 113 - const atIndex = range.lastIndexOf('@') 114 - if (atIndex > 4) { 115 - // After 'npm:' 116 - const aliasedRange = range.slice(atIndex + 1) 117 - return resolveVersion(aliasedRange, versions) 118 - } 119 - return null 120 - } 121 - 122 - // Handle URLs, git refs, etc. - we can't resolve these 123 - if ( 124 - range.startsWith('http://') || 125 - range.startsWith('https://') || 126 - range.startsWith('git://') || 127 - range.startsWith('git+') || 128 - range.startsWith('file:') || 129 - range.includes('/') 130 - ) { 131 - return null 132 - } 133 - 134 - return maxSatisfying(versions, range) 135 - } 136 - 137 - interface ResolvedDep { 138 - name: string 139 - version: string 140 - size: number 141 - optional: boolean 142 - } 143 - 144 - /** 145 - * Recursively resolve dependencies for a package. 146 - * Uses a breadth-first approach with deduplication. 147 - */ 148 - async function resolveDependencyTree( 149 - rootName: string, 150 - rootVersion: string, 151 - ): Promise<Map<string, ResolvedDep>> { 152 - const resolved = new Map<string, ResolvedDep>() 153 - const queue: Array<{ 154 - name: string 155 - range: string 156 - optional: boolean 157 - }> = [{ name: rootName, range: rootVersion, optional: false }] 158 - const seen = new Set<string>() 159 - 160 - while (queue.length > 0) { 161 - // Process in batches for better parallelism 162 - const batch = queue.splice(0, Math.min(20, queue.length)) 163 - 164 - await Promise.all( 165 - batch.map(async ({ name, range, optional }) => { 166 - // Skip if we've already resolved this package 167 - // (deduplication - use the first version we encounter) 168 - if (seen.has(name)) return 169 - seen.add(name) 170 - 171 - const packument = await fetchPackument(name) 172 - if (!packument) return 173 - 174 - const versions = Object.keys(packument.versions) 175 - const version = resolveVersion(range, versions) 176 - if (!version) return 177 - 178 - const versionData = packument.versions[version] 179 - if (!versionData) return 180 - 181 - // Skip if this package doesn't match our target platform 182 - if (!matchesPlatform(versionData)) return 183 - 184 - // Get unpacked size 185 - const size = (versionData.dist as { unpackedSize?: number })?.unpackedSize ?? 0 186 - 187 - const key = `${name}@${version}` 188 - if (!resolved.has(key)) { 189 - resolved.set(key, { name, version, size, optional }) 190 - } 191 - 192 - // Queue regular dependencies 193 - if (versionData.dependencies) { 194 - for (const [depName, depRange] of Object.entries(versionData.dependencies)) { 195 - if (!seen.has(depName)) { 196 - queue.push({ name: depName, range: depRange, optional: false }) 197 - } 198 - } 199 - } 200 - 201 - // Queue optional dependencies (but mark them as optional) 202 - // Only include if they match our platform 203 - if (versionData.optionalDependencies) { 204 - for (const [depName, depRange] of Object.entries(versionData.optionalDependencies)) { 205 - if (!seen.has(depName)) { 206 - queue.push({ name: depName, range: depRange, optional: true }) 207 - } 208 - } 209 - } 210 - }), 211 - ) 212 - } 213 - 214 - return resolved 215 25 } 216 26 217 27 /**
+184
server/utils/vulnerability-tree.ts
··· 1 + import type { 2 + OsvQueryResponse, 3 + OsvVulnerability, 4 + OsvSeverityLevel, 5 + VulnerabilitySummary, 6 + DependencyDepth, 7 + PackageVulnerabilityInfo, 8 + VulnerabilityTreeResult, 9 + } from '#shared/types' 10 + import { resolveDependencyTree } from './dependency-resolver' 11 + 12 + /** Result of a single OSV query */ 13 + type OsvQueryResult = { status: 'ok'; data: PackageVulnerabilityInfo | null } | { status: 'error' } 14 + 15 + /** 16 + * Query OSV for vulnerabilities in a package 17 + */ 18 + async function queryOsv( 19 + name: string, 20 + version: string, 21 + depth: DependencyDepth, 22 + path: string[], 23 + ): Promise<OsvQueryResult> { 24 + try { 25 + const response = await $fetch<OsvQueryResponse>('https://api.osv.dev/v1/query', { 26 + method: 'POST', 27 + body: { 28 + package: { name, ecosystem: 'npm' }, 29 + version, 30 + }, 31 + }) 32 + 33 + const vulns = response.vulns || [] 34 + if (vulns.length === 0) return { status: 'ok', data: null } 35 + 36 + const counts = { total: vulns.length, critical: 0, high: 0, moderate: 0, low: 0 } 37 + const vulnerabilities: VulnerabilitySummary[] = [] 38 + 39 + const severityOrder: Record<OsvSeverityLevel, number> = { 40 + critical: 0, 41 + high: 1, 42 + moderate: 2, 43 + low: 3, 44 + unknown: 4, 45 + } 46 + 47 + const sortedVulns = [...vulns].sort( 48 + (a, b) => severityOrder[getSeverityLevel(a)] - severityOrder[getSeverityLevel(b)], 49 + ) 50 + 51 + for (const vuln of sortedVulns) { 52 + const severity = getSeverityLevel(vuln) 53 + if (severity === 'critical') counts.critical++ 54 + else if (severity === 'high') counts.high++ 55 + else if (severity === 'moderate') counts.moderate++ 56 + else if (severity === 'low') counts.low++ 57 + 58 + vulnerabilities.push({ 59 + id: vuln.id, 60 + summary: vuln.summary || 'No description available', 61 + severity, 62 + aliases: vuln.aliases || [], 63 + url: getVulnerabilityUrl(vuln), 64 + }) 65 + } 66 + 67 + return { status: 'ok', data: { name, version, depth, path, vulnerabilities, counts } } 68 + } catch (error) { 69 + // oxlint-disable-next-line no-console -- log OSV API failures for debugging 70 + console.warn(`[vuln-tree] OSV query failed for ${name}@${version}:`, error) 71 + return { status: 'error' } 72 + } 73 + } 74 + 75 + function getVulnerabilityUrl(vuln: OsvVulnerability): string { 76 + if (vuln.id.startsWith('GHSA-')) { 77 + return `https://github.com/advisories/${vuln.id}` 78 + } 79 + const cveAlias = vuln.aliases?.find(a => a.startsWith('CVE-')) 80 + if (cveAlias) { 81 + return `https://nvd.nist.gov/vuln/detail/${cveAlias}` 82 + } 83 + return `https://osv.dev/vulnerability/${vuln.id}` 84 + } 85 + 86 + function getSeverityLevel(vuln: OsvVulnerability): OsvSeverityLevel { 87 + const dbSeverity = vuln.database_specific?.severity?.toLowerCase() 88 + if (dbSeverity) { 89 + if (dbSeverity === 'critical') return 'critical' 90 + if (dbSeverity === 'high') return 'high' 91 + if (dbSeverity === 'moderate' || dbSeverity === 'medium') return 'moderate' 92 + if (dbSeverity === 'low') return 'low' 93 + } 94 + 95 + const severityEntry = vuln.severity?.[0] 96 + if (severityEntry?.score) { 97 + const match = severityEntry.score.match(/(?:^|[/:])(\d+(?:\.\d+)?)$/) 98 + if (match?.[1]) { 99 + const score = parseFloat(match[1]) 100 + if (score >= 9.0) return 'critical' 101 + if (score >= 7.0) return 'high' 102 + if (score >= 4.0) return 'moderate' 103 + if (score > 0) return 'low' 104 + } 105 + } 106 + 107 + return 'unknown' 108 + } 109 + 110 + /** 111 + * Analyze entire dependency tree for vulnerabilities. 112 + */ 113 + export const analyzeVulnerabilityTree = defineCachedFunction( 114 + async (name: string, version: string): Promise<VulnerabilityTreeResult> => { 115 + // Resolve all packages in the tree with depth tracking 116 + const resolved = await resolveDependencyTree(name, version, { trackDepth: true }) 117 + 118 + // Convert to array for OSV querying 119 + const packages = [...resolved.values()] 120 + 121 + // Query OSV for all packages in parallel batches 122 + const vulnerablePackages: PackageVulnerabilityInfo[] = [] 123 + let failedQueries = 0 124 + const batchSize = 10 125 + 126 + for (let i = 0; i < packages.length; i += batchSize) { 127 + const batch = packages.slice(i, i + batchSize) 128 + const results = await Promise.all( 129 + batch.map(pkg => queryOsv(pkg.name, pkg.version, pkg.depth!, pkg.path || [])), 130 + ) 131 + 132 + for (const result of results) { 133 + if (result.status === 'error') { 134 + failedQueries++ 135 + } else if (result.data) { 136 + vulnerablePackages.push(result.data) 137 + } 138 + } 139 + } 140 + 141 + // Sort by depth (root → direct → transitive), then by severity 142 + const depthOrder: Record<DependencyDepth, number> = { root: 0, direct: 1, transitive: 2 } 143 + vulnerablePackages.sort((a, b) => { 144 + if (a.depth !== b.depth) return depthOrder[a.depth] - depthOrder[b.depth] 145 + if (a.counts.critical !== b.counts.critical) return b.counts.critical - a.counts.critical 146 + if (a.counts.high !== b.counts.high) return b.counts.high - a.counts.high 147 + if (a.counts.moderate !== b.counts.moderate) return b.counts.moderate - a.counts.moderate 148 + return b.counts.total - a.counts.total 149 + }) 150 + 151 + // Aggregate total counts 152 + const totalCounts = { total: 0, critical: 0, high: 0, moderate: 0, low: 0 } 153 + for (const pkg of vulnerablePackages) { 154 + totalCounts.total += pkg.counts.total 155 + totalCounts.critical += pkg.counts.critical 156 + totalCounts.high += pkg.counts.high 157 + totalCounts.moderate += pkg.counts.moderate 158 + totalCounts.low += pkg.counts.low 159 + } 160 + 161 + // Log critical failures (>50% of queries failed) 162 + if (failedQueries > 0 && failedQueries > packages.length / 2) { 163 + // oxlint-disable-next-line no-console -- critical error logging 164 + console.error( 165 + `[vuln-tree] Critical: ${failedQueries}/${packages.length} OSV queries failed for ${name}@${version}`, 166 + ) 167 + } 168 + 169 + return { 170 + package: name, 171 + version, 172 + vulnerablePackages, 173 + totalPackages: packages.length, 174 + failedQueries, 175 + totalCounts, 176 + } 177 + }, 178 + { 179 + maxAge: 60 * 60, 180 + swr: true, 181 + name: 'vulnerability-tree', 182 + getKey: (name: string, version: string) => `v1:${name}@${version}`, 183 + }, 184 + )
+52 -1
shared/types/osv.ts
··· 4 4 */ 5 5 6 6 /** 7 + * Severity levels in priority order (highest first) 8 + */ 9 + export const SEVERITY_LEVELS = ['critical', 'high', 'moderate', 'low'] as const 10 + 11 + /** 7 12 * Severity level derived from CVSS score 8 13 */ 9 - export type OsvSeverityLevel = 'critical' | 'high' | 'moderate' | 'low' | 'unknown' 14 + export type OsvSeverityLevel = (typeof SEVERITY_LEVELS)[number] | 'unknown' 15 + 16 + /** 17 + * Counts by severity level 18 + */ 19 + export type SeverityCounts = Record<(typeof SEVERITY_LEVELS)[number], number> 10 20 11 21 /** 12 22 * CVSS severity information from OSV ··· 70 80 package: string 71 81 version: string 72 82 vulnerabilities: VulnerabilitySummary[] 83 + counts: SeverityCounts & { total: number } 84 + } 85 + 86 + /** Depth in dependency tree */ 87 + export type DependencyDepth = 'root' | 'direct' | 'transitive' 88 + 89 + /** 90 + * Vulnerability info for a single package in the tree 91 + */ 92 + export interface PackageVulnerabilityInfo { 93 + name: string 94 + version: string 95 + /** Depth in dependency tree: root (0), direct (1), transitive (2+) */ 96 + depth: DependencyDepth 97 + /** Dependency path from root package */ 98 + path: string[] 99 + vulnerabilities: VulnerabilitySummary[] 73 100 counts: { 74 101 total: number 75 102 critical: number ··· 78 105 low: number 79 106 } 80 107 } 108 + 109 + /** 110 + * Result of vulnerability tree analysis 111 + */ 112 + export interface VulnerabilityTreeResult { 113 + /** Root package name */ 114 + package: string 115 + /** Root package version */ 116 + version: string 117 + /** All packages with vulnerabilities in the tree */ 118 + vulnerablePackages: PackageVulnerabilityInfo[] 119 + /** Total packages analyzed */ 120 + totalPackages: number 121 + /** Number of packages that could not be checked (OSV query failed) */ 122 + failedQueries: number 123 + /** Aggregated counts across all packages */ 124 + totalCounts: { 125 + total: number 126 + critical: number 127 + high: number 128 + moderate: number 129 + low: number 130 + } 131 + }
+45
shared/utils/severity.ts
··· 1 + import type { OsvSeverityLevel } from '../types' 2 + import { SEVERITY_LEVELS } from '../types' 3 + 4 + /** 5 + * Color classes for severity levels (banner style) 6 + */ 7 + export const SEVERITY_COLORS: Record<OsvSeverityLevel, string> = { 8 + critical: 'text-red-300 bg-red-500/15 border-red-500/40', 9 + high: 'text-red-400 bg-red-500/10 border-red-500/30', 10 + moderate: 'text-orange-400 bg-orange-500/10 border-orange-500/30', 11 + low: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30', 12 + unknown: 'text-fg-muted bg-bg-subtle border-border', 13 + } 14 + 15 + /** 16 + * Color classes for inline severity indicators 17 + */ 18 + export const SEVERITY_TEXT_COLORS: Record<OsvSeverityLevel, string> = { 19 + critical: 'text-red-500', 20 + high: 'text-orange-500', 21 + moderate: 'text-yellow-500', 22 + low: 'text-blue-500', 23 + unknown: 'text-fg-subtle', 24 + } 25 + 26 + /** 27 + * Badge color classes for severity levels 28 + */ 29 + export const SEVERITY_BADGE_COLORS: Record<OsvSeverityLevel, string> = { 30 + critical: 'bg-bg-muted border border-border text-fg', 31 + high: 'bg-bg-muted border border-border text-fg-muted', 32 + moderate: 'bg-bg-muted border border-border text-fg-muted', 33 + low: 'bg-bg-muted border border-border text-fg-subtle', 34 + unknown: 'bg-bg-muted border border-border text-fg-subtle', 35 + } 36 + 37 + /** 38 + * Get highest severity from counts 39 + */ 40 + export function getHighestSeverity(counts: Record<string, number>): OsvSeverityLevel { 41 + for (const s of SEVERITY_LEVELS) { 42 + if ((counts[s] ?? 0) > 0) return s 43 + } 44 + return 'unknown' 45 + }
+40 -15
test/nuxt/components.spec.ts
··· 77 77 import OperationsQueue from '~/components/OperationsQueue.vue' 78 78 import PackageList from '~/components/PackageList.vue' 79 79 import PackageMetricsBadges from '~/components/PackageMetricsBadges.vue' 80 - import PackageVulnerabilities from '~/components/PackageVulnerabilities.vue' 81 80 import PackageAccessControls from '~/components/PackageAccessControls.vue' 82 81 import OrgMembersPanel from '~/components/OrgMembersPanel.vue' 83 82 import OrgTeamsPanel from '~/components/OrgTeamsPanel.vue' 84 83 import CodeMobileTreeDrawer from '~/components/CodeMobileTreeDrawer.vue' 84 + import PackageVulnerabilityTree from '~/components/PackageVulnerabilityTree.vue' 85 + import DependencyPathPopup from '~/components/DependencyPathPopup.vue' 85 86 86 87 describe('component accessibility audits', () => { 87 88 describe('DateTime', () => { ··· 432 433 describe('PackageDependencies', () => { 433 434 it('should have no accessibility violations without dependencies', async () => { 434 435 const component = await mountSuspended(PackageDependencies, { 435 - props: { packageName: 'test-package' }, 436 + props: { packageName: 'test-package', version: '1.0.0' }, 436 437 }) 437 438 const results = await runAxe(component) 438 439 expect(results.violations).toEqual([]) ··· 442 443 const component = await mountSuspended(PackageDependencies, { 443 444 props: { 444 445 packageName: 'test-package', 446 + version: '1.0.0', 445 447 dependencies: { 446 448 vue: '^3.0.0', 447 449 lodash: '^4.17.0', ··· 456 458 const component = await mountSuspended(PackageDependencies, { 457 459 props: { 458 460 packageName: 'test-package', 461 + version: '1.0.0', 459 462 peerDependencies: { 460 463 vue: '^3.0.0', 461 464 }, ··· 816 819 }) 817 820 }) 818 821 819 - describe('PackageVulnerabilities', () => { 820 - it('should have no accessibility violations', async () => { 821 - const component = await mountSuspended(PackageVulnerabilities, { 822 - props: { 823 - packageName: 'lodash', 824 - version: '4.17.21', 825 - }, 826 - }) 827 - const results = await runAxe(component) 828 - expect(results.violations).toEqual([]) 829 - }) 830 - }) 831 - 832 822 describe('PackageAccessControls', () => { 833 823 it('should have no accessibility violations', async () => { 834 824 const component = await mountSuspended(PackageAccessControls, { ··· 885 875 tree: mockTree, 886 876 currentPath: '', 887 877 baseUrl: '/code/vue', 878 + }, 879 + }) 880 + const results = await runAxe(component) 881 + expect(results.violations).toEqual([]) 882 + }) 883 + }) 884 + 885 + describe('PackageVulnerabilityTree', () => { 886 + it('should have no accessibility violations in idle state', async () => { 887 + const component = await mountSuspended(PackageVulnerabilityTree, { 888 + props: { 889 + packageName: 'vue', 890 + version: '3.5.0', 891 + }, 892 + }) 893 + const results = await runAxe(component) 894 + expect(results.violations).toEqual([]) 895 + }) 896 + }) 897 + 898 + describe('DependencyPathPopup', () => { 899 + it('should have no accessibility violations with short path', async () => { 900 + const component = await mountSuspended(DependencyPathPopup, { 901 + props: { 902 + path: ['root@1.0.0', 'vuln-dep@2.0.0'], 903 + }, 904 + }) 905 + const results = await runAxe(component) 906 + expect(results.violations).toEqual([]) 907 + }) 908 + 909 + it('should have no accessibility violations with deep path', async () => { 910 + const component = await mountSuspended(DependencyPathPopup, { 911 + props: { 912 + 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'], 888 913 }, 889 914 }) 890 915 const results = await runAxe(component)
+152
test/unit/dependency-resolver.spec.ts
··· 1 + import { describe, expect, it, vi } from 'vitest' 2 + import type { PackumentVersion } from '../../shared/types' 3 + 4 + // Mock Nitro globals before importing the module 5 + vi.stubGlobal('defineCachedFunction', (fn: Function) => fn) 6 + vi.stubGlobal('$fetch', vi.fn()) 7 + 8 + const { TARGET_PLATFORM, matchesPlatform, resolveVersion } = 9 + await import('../../server/utils/dependency-resolver') 10 + 11 + describe('dependency-resolver', () => { 12 + describe('TARGET_PLATFORM', () => { 13 + it('is configured for linux-x64-glibc', () => { 14 + expect(TARGET_PLATFORM).toEqual({ 15 + os: 'linux', 16 + cpu: 'x64', 17 + libc: 'glibc', 18 + }) 19 + }) 20 + }) 21 + 22 + describe('matchesPlatform', () => { 23 + it('returns true for packages without platform restrictions', () => { 24 + const version = {} as PackumentVersion 25 + expect(matchesPlatform(version)).toBe(true) 26 + }) 27 + 28 + it('returns true when os includes linux', () => { 29 + const version = { os: ['linux', 'darwin'] } as PackumentVersion 30 + expect(matchesPlatform(version)).toBe(true) 31 + }) 32 + 33 + it('returns false when os excludes linux', () => { 34 + const version = { os: ['darwin', 'win32'] } as PackumentVersion 35 + expect(matchesPlatform(version)).toBe(false) 36 + }) 37 + 38 + it('handles negated os values (!linux)', () => { 39 + const version = { os: ['!win32'] } as PackumentVersion 40 + expect(matchesPlatform(version)).toBe(true) 41 + 42 + const excluded = { os: ['!linux'] } as PackumentVersion 43 + expect(matchesPlatform(excluded)).toBe(false) 44 + }) 45 + 46 + it('returns true when cpu includes x64', () => { 47 + const version = { cpu: ['x64', 'arm64'] } as PackumentVersion 48 + expect(matchesPlatform(version)).toBe(true) 49 + }) 50 + 51 + it('returns false when cpu excludes x64', () => { 52 + const version = { cpu: ['arm64', 'arm'] } as PackumentVersion 53 + expect(matchesPlatform(version)).toBe(false) 54 + }) 55 + 56 + it('handles negated cpu values (!x64)', () => { 57 + const version = { cpu: ['!arm64'] } as PackumentVersion 58 + expect(matchesPlatform(version)).toBe(true) 59 + 60 + const excluded = { cpu: ['!x64'] } as PackumentVersion 61 + expect(matchesPlatform(excluded)).toBe(false) 62 + }) 63 + 64 + it('returns true when libc includes glibc', () => { 65 + const version = { libc: ['glibc'] } as unknown as PackumentVersion 66 + expect(matchesPlatform(version)).toBe(true) 67 + }) 68 + 69 + it('returns false when libc is musl only', () => { 70 + const version = { libc: ['musl'] } as unknown as PackumentVersion 71 + expect(matchesPlatform(version)).toBe(false) 72 + }) 73 + 74 + it('handles negated libc values (!glibc)', () => { 75 + const version = { libc: ['!musl'] } as unknown as PackumentVersion 76 + expect(matchesPlatform(version)).toBe(true) 77 + 78 + const excluded = { libc: ['!glibc'] } as unknown as PackumentVersion 79 + expect(matchesPlatform(excluded)).toBe(false) 80 + }) 81 + 82 + it('requires all platform constraints to match', () => { 83 + const version = { 84 + os: ['linux'], 85 + cpu: ['arm64'], // doesn't match x64 86 + } as PackumentVersion 87 + expect(matchesPlatform(version)).toBe(false) 88 + }) 89 + 90 + it('ignores empty arrays', () => { 91 + const version = { os: [], cpu: [], libc: [] } as unknown as PackumentVersion 92 + expect(matchesPlatform(version)).toBe(true) 93 + }) 94 + }) 95 + 96 + describe('resolveVersion', () => { 97 + const versions = ['1.0.0', '1.0.1', '1.1.0', '2.0.0', '2.0.0-beta.1', '3.0.0'] 98 + 99 + it('returns exact version if it exists', () => { 100 + expect(resolveVersion('1.0.0', versions)).toBe('1.0.0') 101 + expect(resolveVersion('2.0.0', versions)).toBe('2.0.0') 102 + }) 103 + 104 + it('returns null for exact version that does not exist', () => { 105 + expect(resolveVersion('1.0.2', versions)).toBe(null) 106 + }) 107 + 108 + it('resolves semver ranges', () => { 109 + expect(resolveVersion('^1.0.0', versions)).toBe('1.1.0') 110 + expect(resolveVersion('~1.0.0', versions)).toBe('1.0.1') 111 + expect(resolveVersion('>=2.0.0', versions)).toBe('3.0.0') 112 + expect(resolveVersion('<2.0.0', versions)).toBe('1.1.0') 113 + }) 114 + 115 + it('resolves * to latest stable', () => { 116 + expect(resolveVersion('*', versions)).toBe('3.0.0') 117 + }) 118 + 119 + it('handles npm: protocol aliases', () => { 120 + expect(resolveVersion('npm:other-pkg@^1.0.0', versions)).toBe('1.1.0') 121 + expect(resolveVersion('npm:@scope/pkg@2.0.0', versions)).toBe('2.0.0') 122 + }) 123 + 124 + it('returns null for invalid npm: protocol', () => { 125 + expect(resolveVersion('npm:', versions)).toBe(null) 126 + expect(resolveVersion('npm:pkg', versions)).toBe(null) 127 + }) 128 + 129 + it('returns null for URLs', () => { 130 + expect(resolveVersion('https://github.com/user/repo', versions)).toBe(null) 131 + expect(resolveVersion('http://example.com/pkg.tgz', versions)).toBe(null) 132 + expect(resolveVersion('git://github.com/user/repo.git', versions)).toBe(null) 133 + expect(resolveVersion('git+https://github.com/user/repo.git', versions)).toBe(null) 134 + }) 135 + 136 + it('returns null for file: protocol', () => { 137 + expect(resolveVersion('file:../local-pkg', versions)).toBe(null) 138 + }) 139 + 140 + it('returns null for GitHub shorthand (contains /)', () => { 141 + expect(resolveVersion('user/repo', versions)).toBe(null) 142 + expect(resolveVersion('user/repo#branch', versions)).toBe(null) 143 + }) 144 + 145 + it('handles prerelease versions when explicitly requested', () => { 146 + // Exact prerelease version match 147 + expect(resolveVersion('2.0.0-beta.1', versions)).toBe('2.0.0-beta.1') 148 + // Range with prerelease - semver correctly prefers stable 2.0.0 over 2.0.0-beta.1 149 + expect(resolveVersion('^2.0.0-beta.0', versions)).toBe('2.0.0') 150 + }) 151 + }) 152 + })
+91
test/unit/severity.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { 3 + SEVERITY_COLORS, 4 + SEVERITY_TEXT_COLORS, 5 + SEVERITY_BADGE_COLORS, 6 + getHighestSeverity, 7 + } from '../../shared/utils/severity' 8 + 9 + describe('severity utils', () => { 10 + describe('SEVERITY_COLORS', () => { 11 + it('has colors for all severity levels', () => { 12 + expect(SEVERITY_COLORS.critical).toBeDefined() 13 + expect(SEVERITY_COLORS.high).toBeDefined() 14 + expect(SEVERITY_COLORS.moderate).toBeDefined() 15 + expect(SEVERITY_COLORS.low).toBeDefined() 16 + expect(SEVERITY_COLORS.unknown).toBeDefined() 17 + }) 18 + 19 + it('critical has red colors', () => { 20 + expect(SEVERITY_COLORS.critical).toContain('red') 21 + }) 22 + 23 + it('high has red colors', () => { 24 + expect(SEVERITY_COLORS.high).toContain('red') 25 + }) 26 + 27 + it('moderate has orange colors', () => { 28 + expect(SEVERITY_COLORS.moderate).toContain('orange') 29 + }) 30 + 31 + it('low has yellow colors', () => { 32 + expect(SEVERITY_COLORS.low).toContain('yellow') 33 + }) 34 + }) 35 + 36 + describe('SEVERITY_TEXT_COLORS', () => { 37 + it('has text colors for all severity levels', () => { 38 + expect(SEVERITY_TEXT_COLORS.critical).toContain('text-') 39 + expect(SEVERITY_TEXT_COLORS.high).toContain('text-') 40 + expect(SEVERITY_TEXT_COLORS.moderate).toContain('text-') 41 + expect(SEVERITY_TEXT_COLORS.low).toContain('text-') 42 + expect(SEVERITY_TEXT_COLORS.unknown).toContain('text-') 43 + }) 44 + }) 45 + 46 + describe('SEVERITY_BADGE_COLORS', () => { 47 + it('has badge colors for all severity levels', () => { 48 + expect(SEVERITY_BADGE_COLORS.critical).toBeDefined() 49 + expect(SEVERITY_BADGE_COLORS.high).toBeDefined() 50 + expect(SEVERITY_BADGE_COLORS.moderate).toBeDefined() 51 + expect(SEVERITY_BADGE_COLORS.low).toBeDefined() 52 + expect(SEVERITY_BADGE_COLORS.unknown).toBeDefined() 53 + }) 54 + }) 55 + 56 + describe('getHighestSeverity', () => { 57 + it('returns critical when critical count > 0', () => { 58 + expect(getHighestSeverity({ critical: 1, high: 0, moderate: 0, low: 0 })).toBe('critical') 59 + }) 60 + 61 + it('returns high when high is highest', () => { 62 + expect(getHighestSeverity({ critical: 0, high: 2, moderate: 1, low: 0 })).toBe('high') 63 + }) 64 + 65 + it('returns moderate when moderate is highest', () => { 66 + expect(getHighestSeverity({ critical: 0, high: 0, moderate: 3, low: 1 })).toBe('moderate') 67 + }) 68 + 69 + it('returns low when only low', () => { 70 + expect(getHighestSeverity({ critical: 0, high: 0, moderate: 0, low: 5 })).toBe('low') 71 + }) 72 + 73 + it('returns unknown when all counts are 0', () => { 74 + expect(getHighestSeverity({ critical: 0, high: 0, moderate: 0, low: 0 })).toBe('unknown') 75 + }) 76 + 77 + it('returns unknown for empty object', () => { 78 + expect(getHighestSeverity({})).toBe('unknown') 79 + }) 80 + 81 + it('prioritizes critical over all others', () => { 82 + expect(getHighestSeverity({ critical: 1, high: 10, moderate: 20, low: 30 })).toBe('critical') 83 + }) 84 + 85 + it('handles missing keys gracefully', () => { 86 + expect(getHighestSeverity({ high: 1 })).toBe('high') 87 + expect(getHighestSeverity({ moderate: 1 })).toBe('moderate') 88 + expect(getHighestSeverity({ low: 1 })).toBe('low') 89 + }) 90 + }) 91 + })
+373
test/unit/vulnerability-tree.spec.ts
··· 1 + import { describe, expect, it, vi, beforeEach } from 'vitest' 2 + 3 + // Mock Nitro globals before importing the module 4 + vi.stubGlobal('defineCachedFunction', (fn: Function) => fn) 5 + vi.stubGlobal('$fetch', vi.fn()) 6 + 7 + // Import module under test 8 + const { analyzeVulnerabilityTree } = await import('../../server/utils/vulnerability-tree') 9 + 10 + // Mock the dependency resolver 11 + vi.mock('../../server/utils/dependency-resolver', () => ({ 12 + resolveDependencyTree: vi.fn(), 13 + })) 14 + 15 + const { resolveDependencyTree } = await import('../../server/utils/dependency-resolver') 16 + 17 + describe('vulnerability-tree', () => { 18 + beforeEach(() => { 19 + vi.clearAllMocks() 20 + }) 21 + 22 + describe('analyzeVulnerabilityTree', () => { 23 + it('returns empty result when no packages have vulnerabilities', async () => { 24 + const mockResolved = new Map([ 25 + [ 26 + 'test-pkg@1.0.0', 27 + { 28 + name: 'test-pkg', 29 + version: '1.0.0', 30 + size: 1000, 31 + optional: false, 32 + depth: 'root' as const, 33 + path: ['test-pkg@1.0.0'], 34 + }, 35 + ], 36 + ]) 37 + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) 38 + 39 + // Mock OSV API returning no vulnerabilities 40 + vi.mocked($fetch).mockResolvedValue({ vulns: [] }) 41 + 42 + const result = await analyzeVulnerabilityTree('test-pkg', '1.0.0') 43 + 44 + expect(result.package).toBe('test-pkg') 45 + expect(result.version).toBe('1.0.0') 46 + expect(result.vulnerablePackages).toHaveLength(0) 47 + expect(result.totalPackages).toBe(1) 48 + expect(result.failedQueries).toBe(0) 49 + expect(result.totalCounts).toEqual({ total: 0, critical: 0, high: 0, moderate: 0, low: 0 }) 50 + }) 51 + 52 + it('tracks failed queries when OSV API fails', async () => { 53 + const mockResolved = new Map([ 54 + [ 55 + 'test-pkg@1.0.0', 56 + { 57 + name: 'test-pkg', 58 + version: '1.0.0', 59 + size: 1000, 60 + optional: false, 61 + depth: 'root' as const, 62 + path: ['test-pkg@1.0.0'], 63 + }, 64 + ], 65 + [ 66 + 'dep-a@2.0.0', 67 + { 68 + name: 'dep-a', 69 + version: '2.0.0', 70 + size: 500, 71 + optional: false, 72 + depth: 'direct' as const, 73 + path: ['test-pkg@1.0.0', 'dep-a@2.0.0'], 74 + }, 75 + ], 76 + ]) 77 + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) 78 + 79 + // First call succeeds, second fails 80 + vi.mocked($fetch) 81 + .mockResolvedValueOnce({ vulns: [] }) 82 + .mockRejectedValueOnce(new Error('OSV API error')) 83 + 84 + const result = await analyzeVulnerabilityTree('test-pkg', '1.0.0') 85 + 86 + expect(result.failedQueries).toBe(1) 87 + expect(result.totalPackages).toBe(2) 88 + }) 89 + 90 + it('correctly counts vulnerabilities by severity', async () => { 91 + const mockResolved = new Map([ 92 + [ 93 + 'vuln-pkg@1.0.0', 94 + { 95 + name: 'vuln-pkg', 96 + version: '1.0.0', 97 + size: 1000, 98 + optional: false, 99 + depth: 'root' as const, 100 + path: ['vuln-pkg@1.0.0'], 101 + }, 102 + ], 103 + ]) 104 + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) 105 + 106 + // Mock OSV API returning vulnerabilities with different severities 107 + vi.mocked($fetch).mockResolvedValue({ 108 + vulns: [ 109 + { id: 'GHSA-1', summary: 'Critical vuln', database_specific: { severity: 'CRITICAL' } }, 110 + { id: 'GHSA-2', summary: 'High vuln', database_specific: { severity: 'HIGH' } }, 111 + { id: 'GHSA-3', summary: 'Moderate vuln', database_specific: { severity: 'MODERATE' } }, 112 + { id: 'GHSA-4', summary: 'Low vuln', database_specific: { severity: 'LOW' } }, 113 + ], 114 + }) 115 + 116 + const result = await analyzeVulnerabilityTree('vuln-pkg', '1.0.0') 117 + 118 + expect(result.vulnerablePackages).toHaveLength(1) 119 + expect(result.totalCounts).toEqual({ total: 4, critical: 1, high: 1, moderate: 1, low: 1 }) 120 + 121 + const pkg = result.vulnerablePackages[0] 122 + expect(pkg.counts.critical).toBe(1) 123 + expect(pkg.counts.high).toBe(1) 124 + expect(pkg.counts.moderate).toBe(1) 125 + expect(pkg.counts.low).toBe(1) 126 + }) 127 + 128 + it('includes dependency path in vulnerable packages', async () => { 129 + const mockResolved = new Map([ 130 + [ 131 + 'root@1.0.0', 132 + { 133 + name: 'root', 134 + version: '1.0.0', 135 + size: 1000, 136 + optional: false, 137 + depth: 'root' as const, 138 + path: ['root@1.0.0'], 139 + }, 140 + ], 141 + [ 142 + 'vuln-dep@2.0.0', 143 + { 144 + name: 'vuln-dep', 145 + version: '2.0.0', 146 + size: 500, 147 + optional: false, 148 + depth: 'transitive' as const, 149 + path: ['root@1.0.0', 'middle@1.5.0', 'vuln-dep@2.0.0'], 150 + }, 151 + ], 152 + ]) 153 + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) 154 + 155 + vi.mocked($fetch) 156 + .mockResolvedValueOnce({ vulns: [] }) // root has no vulns 157 + .mockResolvedValueOnce({ 158 + vulns: [ 159 + { id: 'GHSA-test', summary: 'Test vuln', database_specific: { severity: 'HIGH' } }, 160 + ], 161 + }) // vuln-dep has vuln 162 + 163 + const result = await analyzeVulnerabilityTree('root', '1.0.0') 164 + 165 + expect(result.vulnerablePackages).toHaveLength(1) 166 + const vulnPkg = result.vulnerablePackages[0] 167 + expect(vulnPkg.path).toEqual(['root@1.0.0', 'middle@1.5.0', 'vuln-dep@2.0.0']) 168 + expect(vulnPkg.depth).toBe('transitive') 169 + }) 170 + 171 + it('sorts vulnerable packages by depth then severity', async () => { 172 + const mockResolved = new Map([ 173 + [ 174 + 'root@1.0.0', 175 + { 176 + name: 'root', 177 + version: '1.0.0', 178 + size: 1000, 179 + optional: false, 180 + depth: 'root' as const, 181 + path: ['root@1.0.0'], 182 + }, 183 + ], 184 + [ 185 + 'direct-dep@1.0.0', 186 + { 187 + name: 'direct-dep', 188 + version: '1.0.0', 189 + size: 500, 190 + optional: false, 191 + depth: 'direct' as const, 192 + path: ['root@1.0.0', 'direct-dep@1.0.0'], 193 + }, 194 + ], 195 + [ 196 + 'transitive-dep@1.0.0', 197 + { 198 + name: 'transitive-dep', 199 + version: '1.0.0', 200 + size: 300, 201 + optional: false, 202 + depth: 'transitive' as const, 203 + path: ['root@1.0.0', 'direct-dep@1.0.0', 'transitive-dep@1.0.0'], 204 + }, 205 + ], 206 + ]) 207 + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) 208 + 209 + // All have vulnerabilities 210 + vi.mocked($fetch) 211 + .mockResolvedValueOnce({ 212 + vulns: [ 213 + { id: 'GHSA-root', summary: 'Root vuln', database_specific: { severity: 'LOW' } }, 214 + ], 215 + }) 216 + .mockResolvedValueOnce({ 217 + vulns: [ 218 + { 219 + id: 'GHSA-direct', 220 + summary: 'Direct vuln', 221 + database_specific: { severity: 'CRITICAL' }, 222 + }, 223 + ], 224 + }) 225 + .mockResolvedValueOnce({ 226 + vulns: [ 227 + { id: 'GHSA-trans', summary: 'Trans vuln', database_specific: { severity: 'HIGH' } }, 228 + ], 229 + }) 230 + 231 + const result = await analyzeVulnerabilityTree('root', '1.0.0') 232 + 233 + expect(result.vulnerablePackages).toHaveLength(3) 234 + // Should be sorted: root first, then direct, then transitive 235 + expect(result.vulnerablePackages[0].name).toBe('root') 236 + expect(result.vulnerablePackages[1].name).toBe('direct-dep') 237 + expect(result.vulnerablePackages[2].name).toBe('transitive-dep') 238 + }) 239 + 240 + it('generates correct vulnerability URLs for GHSA', async () => { 241 + const mockResolved = new Map([ 242 + [ 243 + 'pkg@1.0.0', 244 + { 245 + name: 'pkg', 246 + version: '1.0.0', 247 + size: 1000, 248 + optional: false, 249 + depth: 'root' as const, 250 + path: ['pkg@1.0.0'], 251 + }, 252 + ], 253 + ]) 254 + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) 255 + 256 + vi.mocked($fetch).mockResolvedValue({ 257 + vulns: [ 258 + { 259 + id: 'GHSA-xxxx-yyyy-zzzz', 260 + summary: 'Test vuln', 261 + database_specific: { severity: 'HIGH' }, 262 + }, 263 + ], 264 + }) 265 + 266 + const result = await analyzeVulnerabilityTree('pkg', '1.0.0') 267 + 268 + expect(result.vulnerablePackages[0].vulnerabilities[0].url).toBe( 269 + 'https://github.com/advisories/GHSA-xxxx-yyyy-zzzz', 270 + ) 271 + }) 272 + 273 + it('generates correct vulnerability URLs for CVE aliases', async () => { 274 + const mockResolved = new Map([ 275 + [ 276 + 'pkg@1.0.0', 277 + { 278 + name: 'pkg', 279 + version: '1.0.0', 280 + size: 1000, 281 + optional: false, 282 + depth: 'root' as const, 283 + path: ['pkg@1.0.0'], 284 + }, 285 + ], 286 + ]) 287 + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) 288 + 289 + vi.mocked($fetch).mockResolvedValue({ 290 + vulns: [ 291 + { 292 + id: 'OSV-2024-001', 293 + summary: 'Test vuln', 294 + aliases: ['CVE-2024-12345'], 295 + database_specific: { severity: 'HIGH' }, 296 + }, 297 + ], 298 + }) 299 + 300 + const result = await analyzeVulnerabilityTree('pkg', '1.0.0') 301 + 302 + expect(result.vulnerablePackages[0].vulnerabilities[0].url).toBe( 303 + 'https://nvd.nist.gov/vuln/detail/CVE-2024-12345', 304 + ) 305 + }) 306 + 307 + it('falls back to OSV URL for other vulnerability IDs', async () => { 308 + const mockResolved = new Map([ 309 + [ 310 + 'pkg@1.0.0', 311 + { 312 + name: 'pkg', 313 + version: '1.0.0', 314 + size: 1000, 315 + optional: false, 316 + depth: 'root' as const, 317 + path: ['pkg@1.0.0'], 318 + }, 319 + ], 320 + ]) 321 + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) 322 + 323 + vi.mocked($fetch).mockResolvedValue({ 324 + vulns: [ 325 + { id: 'PYSEC-2024-001', summary: 'Test vuln', database_specific: { severity: 'HIGH' } }, 326 + ], 327 + }) 328 + 329 + const result = await analyzeVulnerabilityTree('pkg', '1.0.0') 330 + 331 + expect(result.vulnerablePackages[0].vulnerabilities[0].url).toBe( 332 + 'https://osv.dev/vulnerability/PYSEC-2024-001', 333 + ) 334 + }) 335 + 336 + it('extracts severity from CVSS score when database_specific is missing', async () => { 337 + const mockResolved = new Map([ 338 + [ 339 + 'pkg@1.0.0', 340 + { 341 + name: 'pkg', 342 + version: '1.0.0', 343 + size: 1000, 344 + optional: false, 345 + depth: 'root' as const, 346 + path: ['pkg@1.0.0'], 347 + }, 348 + ], 349 + ]) 350 + vi.mocked(resolveDependencyTree).mockResolvedValue(mockResolved) 351 + 352 + vi.mocked($fetch).mockResolvedValue({ 353 + vulns: [ 354 + { 355 + id: 'GHSA-1', 356 + summary: 'Critical (9.5)', 357 + severity: [{ score: 'CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H/9.5' }], 358 + }, 359 + { id: 'GHSA-2', summary: 'High (7.5)', severity: [{ score: '7.5' }] }, 360 + { id: 'GHSA-3', summary: 'Moderate (5.0)', severity: [{ score: '5.0' }] }, 361 + { id: 'GHSA-4', summary: 'Low (2.0)', severity: [{ score: '2.0' }] }, 362 + ], 363 + }) 364 + 365 + const result = await analyzeVulnerabilityTree('pkg', '1.0.0') 366 + 367 + expect(result.totalCounts.critical).toBe(1) 368 + expect(result.totalCounts.high).toBe(1) 369 + expect(result.totalCounts.moderate).toBe(1) 370 + expect(result.totalCounts.low).toBe(1) 371 + }) 372 + }) 373 + })