Skip to content

Commit ea77dcf

Browse files
committed
feat: implement dev vulnerability check
1 parent bf55ec9 commit ea77dcf

6 files changed

Lines changed: 449 additions & 93 deletions

File tree

app/components/PackageDependencies.vue

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
<script setup lang="ts">
2+
import { SEVERITY_LEVELS } from '~~/shared/types'
3+
24
const props = defineProps<{
35
packageName: string
46
dependencies?: Record<string, string>
@@ -10,6 +12,9 @@ const props = defineProps<{
1012
// Fetch outdated info for dependencies
1113
const outdatedDeps = useOutdatedDependencies(() => props.dependencies)
1214
15+
// Fetch vulnerability info for dependencies
16+
const vulnerableDeps = useVulnerableDependencies(() => props.dependencies)
17+
1318
// Expanded state for each section
1419
const depsExpanded = shallowRef(false)
1520
const peerDepsExpanded = shallowRef(false)
@@ -43,10 +48,65 @@ const sortedOptionalDependencies = computed(() => {
4348
if (!props.optionalDependencies) return []
4449
return Object.entries(props.optionalDependencies).sort(([a], [b]) => a.localeCompare(b))
4550
})
51+
52+
// Vulnerability summary for banner
53+
const vulnerabilitySummary = computed(() => {
54+
const deps = Object.values(vulnerableDeps.value)
55+
if (deps.length === 0) return null
56+
57+
const counts = { critical: 0, high: 0, moderate: 0, low: 0 }
58+
let total = 0
59+
60+
for (const info of deps) {
61+
if (!info?.counts) continue
62+
total += info.counts.total || 0
63+
for (const s of SEVERITY_LEVELS) counts[s] += info.counts[s] || 0
64+
}
65+
66+
const severity = SEVERITY_LEVELS.find(s => counts[s] > 0) || 'low'
67+
68+
return { affectedDeps: deps.length, totalVulns: total, severity, counts }
69+
})
70+
71+
const vulnBreakdownText = computed(() => {
72+
if (!vulnerabilitySummary.value) return ''
73+
const { counts } = vulnerabilitySummary.value
74+
return SEVERITY_LEVELS.filter(s => counts[s])
75+
.map(s => `${counts[s]} ${s}`)
76+
.join(', ')
77+
})
4678
</script>
4779

4880
<template>
4981
<div class="space-y-8">
82+
<!-- Vulnerability warning banner -->
83+
<div
84+
v-if="vulnerabilitySummary"
85+
role="alert"
86+
class="rounded-lg border px-4 py-3 cursor-help"
87+
:class="{
88+
'border-red-500/30 bg-red-500/10 text-red-400':
89+
vulnerabilitySummary.severity === 'critical',
90+
'border-orange-500/30 bg-orange-500/10 text-orange-400':
91+
vulnerabilitySummary.severity === 'high',
92+
'border-yellow-500/30 bg-yellow-500/10 text-yellow-400':
93+
vulnerabilitySummary.severity === 'moderate',
94+
'border-blue-500/30 bg-blue-500/10 text-blue-400': vulnerabilitySummary.severity === 'low',
95+
}"
96+
:title="`${vulnerabilitySummary.affectedDeps} ${vulnerabilitySummary.affectedDeps === 1 ? 'dependency' : 'dependencies'} affected`"
97+
>
98+
<div class="flex items-center gap-2">
99+
<span class="i-carbon-security w-4 h-4 shrink-0" aria-hidden="true" />
100+
<div>
101+
<div class="font-mono text-sm">
102+
{{ vulnerabilitySummary.totalVulns }}
103+
{{ vulnerabilitySummary.totalVulns === 1 ? 'vulnerability' : 'vulnerabilities' }}
104+
</div>
105+
<div class="font-mono text-xs opacity-70">{{ vulnBreakdownText }}</div>
106+
</div>
107+
</div>
108+
</div>
109+
50110
<!-- Dependencies -->
51111
<section v-if="sortedDependencies.length > 0" aria-labelledby="dependencies-heading">
52112
<h2 id="dependencies-heading" class="text-xs text-fg-subtle uppercase tracking-wider mb-3">
@@ -74,6 +134,19 @@ const sortedOptionalDependencies = computed(() => {
74134
>
75135
<span class="i-carbon-warning-alt w-3 h-3 block" />
76136
</span>
137+
<NuxtLink
138+
v-if="vulnerableDeps[dep]?.version"
139+
:to="{
140+
name: 'package',
141+
params: { package: [...dep.split('/'), 'v', vulnerableDeps[dep].version] },
142+
}"
143+
class="shrink-0"
144+
:class="getVulnerabilitySeverityClass(vulnerableDeps[dep])"
145+
:title="getVulnerabilityTooltip(vulnerableDeps[dep])"
146+
>
147+
<span class="i-carbon-security w-3 h-3 block" aria-hidden="true" />
148+
<span class="sr-only">View vulnerabilities</span>
149+
</NuxtLink>
77150
<NuxtLink
78151
:to="{ name: 'package', params: { package: [...dep.split('/'), 'v', version] } }"
79152
class="font-mono text-xs text-right truncate"
@@ -85,6 +158,9 @@ const sortedOptionalDependencies = computed(() => {
85158
<span v-if="outdatedDeps[dep]" class="sr-only">
86159
({{ getOutdatedTooltip(outdatedDeps[dep]) }})
87160
</span>
161+
<span v-if="vulnerableDeps[dep]" class="sr-only">
162+
({{ getVulnerabilityTooltip(vulnerableDeps[dep]) }})
163+
</span>
88164
</span>
89165
</li>
90166
</ul>

app/components/PackageVulnerabilities.vue

Lines changed: 17 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,5 @@
11
<script setup lang="ts">
2-
import type {
3-
OsvQueryResponse,
4-
OsvVulnerability,
5-
OsvSeverityLevel,
6-
VulnerabilitySummary,
7-
} from '#shared/types'
2+
import type { OsvSeverityLevel, PackageVulnerabilities } from '#shared/types'
83
94
const props = defineProps<{
105
packageName: string
@@ -14,94 +9,34 @@ const props = defineProps<{
149
const { data: vulnData, status } = useLazyAsyncData(
1510
`osv-${props.packageName}@${props.version}`,
1611
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',
12+
const response = await $fetch<{ results: Record<string, PackageVulnerabilities> }>(
13+
'/api/osv/vulnerabilities',
14+
{
15+
method: 'POST',
16+
body: {
17+
packages: [{ name: props.packageName, version: props.version }],
2318
},
24-
version: props.version,
2519
},
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++
20+
)
21+
22+
const result = response.results[props.packageName]
23+
if (!result) {
24+
return {
25+
vulnerabilities: [],
26+
counts: { total: 0, critical: 0, high: 0, moderate: 0, low: 0 },
27+
}
4828
}
4929
50-
return { vulnerabilities, counts }
30+
return { vulnerabilities: result.vulnerabilities, counts: result.counts }
5131
},
5232
{
5333
default: () => ({
54-
vulnerabilities: [] as VulnerabilitySummary[],
34+
vulnerabilities: [],
5535
counts: { total: 0, critical: 0, high: 0, moderate: 0, low: 0 },
5636
}),
5737
},
5838
)
5939
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-
10540
const hasVulnerabilities = computed(() => vulnData.value.counts.total > 0)
10641
10742
// Severity color classes for the banner

app/composables/useNpmRegistry.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ async function fetchNpmPackage(name: string): Promise<Packument> {
3232
* Fetch a package's packument with caching (returns null on error).
3333
* This is useful for batch operations where some packages might not exist.
3434
*/
35-
async function fetchCachedPackument(name: string): Promise<Packument | null> {
35+
export async function fetchCachedPackument(name: string): Promise<Packument | null> {
3636
const cached = packumentCache.get(name)
3737
if (cached) return cached
3838

@@ -472,7 +472,7 @@ export interface OutdatedDependencyInfo {
472472
* Check if a version constraint explicitly includes a prerelease tag.
473473
* e.g., "^1.0.0-alpha" or ">=2.0.0-beta.1" include prereleases
474474
*/
475-
function constraintIncludesPrerelease(constraint: string): boolean {
475+
export function constraintIncludesPrerelease(constraint: string): boolean {
476476
return (
477477
/-(alpha|beta|rc|next|canary|dev|preview|pre|experimental)/i.test(constraint) ||
478478
/-\d/.test(constraint)
@@ -482,7 +482,7 @@ function constraintIncludesPrerelease(constraint: string): boolean {
482482
/**
483483
* Check if a constraint is a non-semver value (git URL, file path, etc.)
484484
*/
485-
function isNonSemverConstraint(constraint: string): boolean {
485+
export function isNonSemverConstraint(constraint: string): boolean {
486486
return (
487487
constraint.startsWith('git') ||
488488
constraint.startsWith('http') ||

0 commit comments

Comments
 (0)