Skip to content

Commit 154d47b

Browse files
authored
feat: show vulnerability warnings for direct dependencies (#167)
1 parent 37afb2d commit 154d47b

21 files changed

+1721
-463
lines changed
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
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>

app/components/PackageDependencies.vue

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
<script setup lang="ts">
2+
import { useVulnerabilityTree } from '~/composables/useVulnerabilityTree'
3+
import { SEVERITY_TEXT_COLORS, getHighestSeverity } from '#shared/utils/severity'
4+
25
const props = defineProps<{
36
packageName: string
7+
version: string
48
dependencies?: Record<string, string>
59
peerDependencies?: Record<string, string>
610
peerDependenciesMeta?: Record<string, { optional?: boolean }>
@@ -10,6 +14,18 @@ const props = defineProps<{
1014
// Fetch outdated info for dependencies
1115
const outdatedDeps = useOutdatedDependencies(() => props.dependencies)
1216
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+
}
28+
1329
// Expanded state for each section
1430
const depsExpanded = shallowRef(false)
1531
const peerDepsExpanded = shallowRef(false)
@@ -91,6 +107,19 @@ const sortedOptionalDependencies = computed(() => {
91107
>
92108
<span class="i-carbon-warning-alt w-3 h-3 block" />
93109
</span>
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>
94123
<NuxtLink
95124
:to="{ name: 'package', params: { package: [...dep.split('/'), 'v', version] } }"
96125
class="font-mono text-xs text-right truncate"
@@ -102,6 +131,9 @@ const sortedOptionalDependencies = computed(() => {
102131
<span v-if="outdatedDeps[dep]" class="sr-only">
103132
({{ getOutdatedTooltip(outdatedDeps[dep]) }})
104133
</span>
134+
<span v-if="getVulnerableDepInfo(dep)" class="sr-only">
135+
({{ getVulnerableDepInfo(dep)!.counts.total }} vulnerabilities)
136+
</span>
105137
</span>
106138
</li>
107139
</ul>

0 commit comments

Comments
 (0)