Skip to content

Commit e6f7794

Browse files
committed
feat: display vulnerabilities (based on osv data)
1 parent 4ca3651 commit e6f7794

4 files changed

Lines changed: 331 additions & 2 deletions

File tree

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
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 || 'No description available',
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 = ref(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) parts.push(`${counts.critical} critical`)
143+
if (counts.high > 0) parts.push(`${counts.high} high`)
144+
if (counts.moderate > 0) parts.push(`${counts.moderate} moderate`)
145+
if (counts.low > 0) parts.push(`${counts.low} low`)
146+
return parts.join(', ')
147+
})
148+
</script>
149+
150+
<template>
151+
<div v-if="status === 'success' && hasVulnerabilities" class="mb-6">
152+
<!-- Collapsible vulnerability banner -->
153+
<div
154+
role="alert"
155+
class="rounded-lg border overflow-hidden"
156+
:class="severityColors[highestSeverity]"
157+
>
158+
<!-- Header (always visible, clickable to expand) -->
159+
<button
160+
type="button"
161+
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"
162+
:aria-expanded="isExpanded"
163+
aria-controls="vulnerability-details"
164+
@click="isExpanded = !isExpanded"
165+
>
166+
<div class="flex items-center gap-2 min-w-0">
167+
<span class="i-carbon-warning-alt w-4 h-4 shrink-0" aria-hidden="true" />
168+
<span class="font-mono text-sm font-medium truncate">
169+
{{ vulnData.counts.total }}
170+
{{ vulnData.counts.total === 1 ? 'vulnerability' : 'vulnerabilities' }} found
171+
</span>
172+
</div>
173+
<div class="flex items-center gap-2 shrink-0">
174+
<span class="text-xs opacity-80 hidden sm:inline">{{ summaryText }}</span>
175+
<span
176+
class="i-carbon-chevron-down w-4 h-4 transition-transform duration-200"
177+
:class="{ 'rotate-180': isExpanded }"
178+
aria-hidden="true"
179+
/>
180+
</div>
181+
</button>
182+
183+
<!-- Expandable details - neutral background for better contrast -->
184+
<div
185+
v-show="isExpanded"
186+
id="vulnerability-details"
187+
class="border-t border-border bg-bg-subtle"
188+
>
189+
<ul class="divide-y divide-border list-none m-0 p-0">
190+
<li
191+
v-for="vuln in vulnData.vulnerabilities"
192+
:key="vuln.id"
193+
class="px-4 py-3 hover:bg-bg-muted transition-colors duration-200"
194+
>
195+
<div class="flex items-start justify-between gap-3">
196+
<div class="min-w-0 flex-1">
197+
<div class="flex items-center gap-2 mb-1">
198+
<a
199+
:href="vuln.url"
200+
target="_blank"
201+
rel="noopener noreferrer"
202+
class="font-mono text-sm font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 rounded"
203+
>
204+
{{ vuln.id }}
205+
</a>
206+
<span
207+
class="px-2 py-0.5 text-xs font-mono rounded"
208+
:class="severityBadgeColors[vuln.severity]"
209+
>
210+
{{ vuln.severity }}
211+
</span>
212+
</div>
213+
<p class="text-sm text-fg-muted line-clamp-2 m-0">
214+
{{ vuln.summary }}
215+
</p>
216+
<div v-if="vuln.aliases.length > 0" class="mt-1">
217+
<span
218+
v-for="alias in vuln.aliases.slice(0, 2)"
219+
:key="alias"
220+
class="text-xs text-fg-subtle mr-2"
221+
>
222+
{{ alias }}
223+
</span>
224+
</div>
225+
</div>
226+
<a
227+
:href="vuln.url"
228+
target="_blank"
229+
rel="noopener noreferrer"
230+
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"
231+
aria-label="View vulnerability details"
232+
>
233+
<span class="i-carbon-launch w-3.5 h-3.5" aria-hidden="true" />
234+
</a>
235+
</div>
236+
</li>
237+
</ul>
238+
</div>
239+
</div>
240+
</div>
241+
</template>

app/pages/[...package].vue

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -431,8 +431,8 @@ defineOgImageComponent('Package', {
431431
rel="noopener noreferrer"
432432
class="link-subtle font-mono text-sm inline-flex items-center gap-1.5"
433433
>
434-
<span class="i-carbon-security w-4 h-4" />
435-
security
434+
<span class="i-simple-icons-socketdotio w-4 h-4" />
435+
socket.dev
436436
</a>
437437
</li>
438438
<li>
@@ -473,6 +473,13 @@ defineOgImageComponent('Package', {
473473
</nav>
474474
</header>
475475

476+
<!-- Security vulnerabilities warning -->
477+
<PackageVulnerabilities
478+
v-if="displayVersion"
479+
:package-name="pkg.name"
480+
:version="displayVersion.version"
481+
/>
482+
476483
<!-- Install command with package manager selector -->
477484
<section aria-labelledby="install-heading" class="mb-8">
478485
<div class="flex items-center justify-between mb-3">

shared/types/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from './npm-registry'
22
export * from './jsr'
3+
export * from './osv'

shared/types/osv.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/**
2+
* OSV (Open Source Vulnerabilities) API types
3+
* @see https://google.github.io/osv.dev/api/
4+
*/
5+
6+
/**
7+
* Severity level derived from CVSS score
8+
*/
9+
export type OsvSeverityLevel = 'critical' | 'high' | 'moderate' | 'low' | 'unknown'
10+
11+
/**
12+
* CVSS severity information from OSV
13+
*/
14+
export interface OsvSeverity {
15+
type: 'CVSS_V3' | 'CVSS_V4'
16+
score: string
17+
}
18+
19+
/**
20+
* Reference link for a vulnerability
21+
*/
22+
export interface OsvReference {
23+
type: 'ADVISORY' | 'WEB' | 'PACKAGE' | 'REPORT' | 'FIX' | 'ARTICLE' | 'DETECTION' | 'EVIDENCE'
24+
url: string
25+
}
26+
27+
/**
28+
* Individual vulnerability record from OSV
29+
*/
30+
export interface OsvVulnerability {
31+
id: string
32+
summary?: string
33+
details?: string
34+
aliases?: string[]
35+
modified: string
36+
published?: string
37+
severity?: OsvSeverity[]
38+
references?: OsvReference[]
39+
database_specific?: {
40+
severity?: string
41+
cwe_ids?: string[]
42+
github_reviewed?: boolean
43+
nvd_published_at?: string
44+
}
45+
}
46+
47+
/**
48+
* OSV API query response
49+
*/
50+
export interface OsvQueryResponse {
51+
vulns?: OsvVulnerability[]
52+
next_page_token?: string
53+
}
54+
55+
/**
56+
* Simplified vulnerability info for display
57+
*/
58+
export interface VulnerabilitySummary {
59+
id: string
60+
summary: string
61+
severity: OsvSeverityLevel
62+
aliases: string[]
63+
url: string
64+
}
65+
66+
/**
67+
* Package vulnerability response returned by our API
68+
*/
69+
export interface PackageVulnerabilities {
70+
package: string
71+
version: string
72+
vulnerabilities: VulnerabilitySummary[]
73+
counts: {
74+
total: number
75+
critical: number
76+
high: number
77+
moderate: number
78+
low: number
79+
}
80+
}

0 commit comments

Comments
 (0)