This commit is contained in:
2026-01-02 11:49:08 +00:00
parent 3b8e171fdb
commit 1cbf6c5d9e
27 changed files with 779 additions and 384 deletions
+420
View File
@@ -0,0 +1,420 @@
<template>
<div class="max-w-7xl mx-auto px-4 py-8">
<!-- Header with Back Button -->
<div class="mb-6">
<Button @click="goBack" variant="ghost" class="mb-4">
<i class="fas fa-arrow-left mr-2"></i>
Back to Packages
</Button>
<div class="flex items-center gap-3">
<i class="fas fa-box text-3xl text-primary-600"></i>
<div>
<h1 class="text-3xl font-bold text-gray-900">{{ packageName }}</h1>
<div class="flex items-center gap-2 mt-2">
<Badge :class="getRegistryBadgeClass(registry)">{{ registry }}</Badge>
<Badge variant="outline" class="font-mono">v{{ version }}</Badge>
</div>
</div>
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-primary-600"></i>
<p class="mt-4 text-gray-600">Loading vulnerability details...</p>
</div>
<!-- Error State -->
<Alert v-else-if="error" variant="destructive" class="mb-4">
<i class="fas fa-exclamation-circle mr-2"></i>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<!-- Vulnerability Details -->
<div v-else-if="vulnerabilities" class="space-y-6">
<!-- Summary Card -->
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<i class="fas fa-shield-virus text-red-600"></i>
Security Scan Summary
</CardTitle>
</CardHeader>
<CardContent>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div class="text-center p-4 bg-red-50 rounded-lg border border-red-200">
<p class="text-3xl font-bold text-red-600">{{ severityCounts.critical }}</p>
<p class="text-sm text-gray-600 mt-1">Critical</p>
</div>
<div class="text-center p-4 bg-orange-50 rounded-lg border border-orange-200">
<p class="text-3xl font-bold text-orange-600">{{ severityCounts.high }}</p>
<p class="text-sm text-gray-600 mt-1">High</p>
</div>
<div class="text-center p-4 bg-yellow-50 rounded-lg border border-yellow-200">
<p class="text-3xl font-bold text-yellow-600">{{ severityCounts.moderate }}</p>
<p class="text-sm text-gray-600 mt-1">Moderate</p>
</div>
<div class="text-center p-4 bg-blue-50 rounded-lg border border-blue-200">
<p class="text-3xl font-bold text-blue-600">{{ severityCounts.low }}</p>
<p class="text-sm text-gray-600 mt-1">Low</p>
</div>
</div>
<Separator class="my-4" />
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-4">
<span class="text-gray-600">
<i class="fas fa-search mr-1"></i>
Scanned: {{ formatDate(vulnerabilities.scanned_at) }}
</span>
<span class="text-gray-600">
<i class="fas fa-cog mr-1"></i>
Scanner: {{ vulnerabilities.scanner }}
</span>
</div>
<div v-if="bypassedCount > 0" class="flex items-center gap-2 text-green-600">
<i class="fas fa-check-circle"></i>
<span>{{ bypassedCount }} bypassed</span>
</div>
</div>
</CardContent>
</Card>
<!-- No Vulnerabilities -->
<Card v-if="vulnerabilityList.length === 0">
<CardContent class="text-center py-12">
<i class="fas fa-check-circle text-6xl text-green-500 mb-4"></i>
<p class="text-xl font-semibold text-gray-900">No Vulnerabilities Found</p>
<p class="mt-2 text-gray-600">This package is clean and safe to use</p>
</CardContent>
</Card>
<!-- Vulnerability List -->
<Card v-else>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<i class="fas fa-list text-gray-600"></i>
Detected Vulnerabilities ({{ vulnerabilityList.length }})
</CardTitle>
</CardHeader>
<CardContent class="p-0">
<div class="border-t">
<Table>
<TableHeader>
<TableRow class="bg-gray-50 hover:bg-gray-50">
<TableHead class="w-[100px]">Severity</TableHead>
<TableHead class="w-[180px]">CVE ID</TableHead>
<TableHead>Description</TableHead>
<TableHead class="w-[120px]">Fix Version</TableHead>
<TableHead class="w-[100px] text-center">Details</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template v-for="(vuln, index) in vulnerabilityList" :key="vuln.id">
<!-- Main Row -->
<TableRow
:class="getRowClass(index, vuln.severity)"
@click="toggleRow(index)"
>
<TableCell>
<Badge :class="getSeverityBadgeClass(vuln.severity)">
{{ formatSeverityName(vuln.severity) }}
</Badge>
<span
v-if="vuln.bypassed"
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800"
title="Bypassed"
>
<i class="fas fa-unlock text-xs"></i>
</span>
</TableCell>
<TableCell>
<span class="font-mono text-sm font-medium">{{ vuln.id }}</span>
</TableCell>
<TableCell>
<p class="text-sm text-gray-900 line-clamp-2">{{ vuln.title || vuln.description }}</p>
</TableCell>
<TableCell>
<span v-if="vuln.fixed_in" class="inline-flex items-center text-sm text-green-700">
<i class="fas fa-arrow-up text-xs mr-1"></i>
v{{ vuln.fixed_in }}
</span>
<span v-else class="text-sm text-gray-400">-</span>
</TableCell>
<TableCell class="text-center">
<Button
variant="ghost"
size="sm"
class="h-8 w-8 p-0"
@click.stop="toggleRow(index)"
>
<i
:class="[
'fas transition-transform',
expandedRows.has(index) ? 'fa-chevron-up' : 'fa-chevron-down'
]"
></i>
</Button>
</TableCell>
</TableRow>
<!-- Expanded Details Row -->
<TableRow v-if="expandedRows.has(index)" class="bg-gray-50 hover:bg-gray-50">
<TableCell colspan="5" class="p-0">
<div class="px-6 py-4 space-y-3">
<!-- Full Description -->
<div>
<h5 class="font-semibold text-gray-900 mb-2">Description</h5>
<div
class="text-sm text-gray-700 leading-relaxed prose prose-sm max-w-none"
v-html="renderMarkdown(vuln.description)"
></div>
</div>
<!-- Bypass Information -->
<div v-if="vuln.bypassed && vuln.bypass" class="bg-green-50 border border-green-200 rounded-lg p-3">
<h5 class="font-semibold text-green-900 mb-2 flex items-center gap-2">
<i class="fas fa-info-circle"></i>
Bypass Active
</h5>
<div class="grid grid-cols-3 gap-4 text-sm text-green-800">
<div>
<span class="font-medium">Reason:</span> {{ vuln.bypass.reason }}
</div>
<div>
<span class="font-medium">By:</span> {{ vuln.bypass.created_by }}
</div>
<div>
<span class="font-medium">Expires:</span> {{ formatDate(vuln.bypass.expires_at) }}
</div>
</div>
</div>
<!-- Fix Information -->
<div v-if="vuln.fixed_in" class="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div class="flex items-center gap-2">
<i class="fas fa-wrench text-blue-700"></i>
<span class="text-sm text-blue-900">
<span class="font-semibold">Fix Available:</span> Upgrade to version <strong>{{ vuln.fixed_in }}</strong> or later
</span>
</div>
</div>
<!-- Primary Reference -->
<div v-if="vuln.references && vuln.references.length > 0" class="flex items-center gap-2 text-sm">
<i class="fas fa-link text-gray-500"></i>
<a
:href="vuln.references[0]"
target="_blank"
rel="noopener noreferrer"
class="text-blue-600 hover:text-blue-800 hover:underline font-medium"
>
View Full Advisory
</a>
<span v-if="vuln.references.length > 1" class="text-gray-500">
(+{{ vuln.references.length - 1 }} more)
</span>
</div>
</div>
</TableCell>
</TableRow>
</template>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import axios from 'axios'
import { marked } from 'marked'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Separator } from '@/components/ui/separator'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
// Configure marked
marked.setOptions({
breaks: true,
gfm: true,
})
const router = useRouter()
const route = useRoute()
const registry = computed(() => route.params.registry as string)
const packageName = computed(() => {
// Handle package names with slashes (e.g., Go packages like github.com/user/repo)
const nameParam = route.params.name
if (Array.isArray(nameParam)) {
return nameParam.join('/')
}
return nameParam as string
})
const version = computed(() => route.params.version as string)
interface BypassInfo {
id: string
reason: string
created_by: string
expires_at: string
}
interface Vulnerability {
id: string
severity: string
title: string
description: string
references: string[]
fixed_in: string
bypassed: boolean
bypass?: BypassInfo
}
interface VulnerabilityResponse {
scanned: boolean
scanner: string
scanned_at: string
status: string
vulnerabilities: Vulnerability[]
vulnerability_count: number
severity_counts: {
critical: number
high: number
moderate: number
low: number
}
bypassed_count: number
}
const loading = ref(false)
const error = ref<string | null>(null)
const vulnerabilities = ref<VulnerabilityResponse | null>(null)
const expandedRows = ref<Set<number>>(new Set())
// Severity order for sorting (higher values = more severe)
const severityOrder: Record<string, number> = {
CRITICAL: 4,
HIGH: 3,
MEDIUM: 2,
MODERATE: 2, // Treat MODERATE same as MEDIUM
LOW: 1,
UNKNOWN: 0,
}
const vulnerabilityList = computed(() => {
const vulns = vulnerabilities.value?.vulnerabilities || []
// Sort by severity (most severe first)
return [...vulns].sort((a, b) => {
const severityA = severityOrder[a.severity.toUpperCase()] || 0
const severityB = severityOrder[b.severity.toUpperCase()] || 0
return severityB - severityA // Descending order
})
})
const severityCounts = computed(() => vulnerabilities.value?.severity_counts || { critical: 0, high: 0, moderate: 0, low: 0 })
const bypassedCount = computed(() => vulnerabilities.value?.bypassed_count || 0)
onMounted(() => {
fetchVulnerabilities()
})
async function fetchVulnerabilities() {
loading.value = true
error.value = null
vulnerabilities.value = null
try {
const response = await axios.get(
`/api/packages/${registry.value}/${packageName.value}/${version.value}/vulnerabilities`
)
// API wraps response in {success: true, data: {...}}
vulnerabilities.value = response.data.data
} catch (err: any) {
console.error('Failed to fetch vulnerabilities:', err)
error.value = err.response?.data?.error?.message || err.message || 'Failed to load vulnerability details'
} finally {
loading.value = false
}
}
function goBack() {
router.push('/packages')
}
function toggleRow(index: number) {
if (expandedRows.value.has(index)) {
expandedRows.value.delete(index)
} else {
expandedRows.value.add(index)
}
}
function getRowClass(index: number, severity: string): string {
const classes = ['cursor-pointer']
if (expandedRows.value.has(index)) {
classes.push('bg-gray-50')
}
classes.push(getVulnerabilityBorderClass(severity))
return classes.join(' ')
}
function getSeverityBadgeClass(severity: string): string {
const classes: Record<string, string> = {
CRITICAL: 'bg-red-600 text-white hover:bg-red-700 border-0',
HIGH: 'bg-orange-500 text-white hover:bg-orange-600 border-0',
MEDIUM: 'bg-yellow-500 text-white hover:bg-yellow-600 border-0',
LOW: 'bg-blue-500 text-white hover:bg-blue-600 border-0',
MODERATE: 'bg-yellow-500 text-white hover:bg-yellow-600 border-0',
}
return classes[severity.toUpperCase()] || 'bg-gray-500 text-white hover:bg-gray-600 border-0'
}
function getVulnerabilityBorderClass(severity: string): string {
const classes: Record<string, string> = {
CRITICAL: 'border-l-4 border-l-red-600',
HIGH: 'border-l-4 border-l-orange-500',
MEDIUM: 'border-l-4 border-l-yellow-500',
MODERATE: 'border-l-4 border-l-yellow-500',
LOW: 'border-l-4 border-l-blue-500',
}
return classes[severity.toUpperCase()] || 'border-l-4 border-l-gray-500'
}
function getRegistryBadgeClass(registry: string): string {
const classes: Record<string, string> = {
npm: 'bg-red-500 text-white border-0',
pypi: 'bg-blue-500 text-white border-0',
go: 'bg-cyan-500 text-white border-0',
}
return classes[registry] || 'bg-gray-500 text-white border-0'
}
function formatDate(date: string): string {
return new Date(date).toLocaleString()
}
function renderMarkdown(text: string): string {
if (!text) return ''
return marked.parse(text) as string
}
function formatSeverityName(severity: string): string {
// Convert severity to title case (e.g., "CRITICAL" -> "Critical", "MODERATE" -> "Moderate")
const normalized = severity.toUpperCase()
return normalized.charAt(0) + normalized.slice(1).toLowerCase()
}
</script>
+32 -18
View File
@@ -13,28 +13,28 @@
<span class="text-sm font-medium text-gray-700">Filter by registry:</span>
<div class="flex gap-2">
<Button
@click="selectedRegistry = 'all'"
@click="changeRegistryFilter('all')"
:variant="selectedRegistry === 'all' ? 'default' : 'outline'"
size="sm"
>
All
</Button>
<Button
@click="selectedRegistry = 'npm'"
@click="changeRegistryFilter('npm')"
:variant="selectedRegistry === 'npm' ? 'default' : 'outline'"
size="sm"
>
<i class="fab fa-npm mr-2"></i>NPM
</Button>
<Button
@click="selectedRegistry = 'pypi'"
@click="changeRegistryFilter('pypi')"
:variant="selectedRegistry === 'pypi' ? 'default' : 'outline'"
size="sm"
>
<i class="fab fa-python mr-2"></i>PyPI
</Button>
<Button
@click="selectedRegistry = 'go'"
@click="changeRegistryFilter('go')"
:variant="selectedRegistry === 'go' ? 'default' : 'outline'"
size="sm"
>
@@ -230,20 +230,13 @@
</DialogContent>
</Dialog>
<!-- Vulnerability Details Modal -->
<VulnerabilityDetailsModal
v-if="selectedPackage"
v-model:open="showVulnerabilityModal"
:registry="selectedPackage.registry"
:package-name="selectedPackage.name"
:version="selectedPackage.version"
/>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, computed, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useRoute, useRouter } from 'vue-router'
import { usePackageStore, type Package } from '../stores/packages'
import {
Accordion,
@@ -265,16 +258,20 @@ import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import VulnerabilityBadge from './VulnerabilityBadge.vue'
import VulnerabilityDetailsModal from './VulnerabilityDetailsModal.vue'
// Props from router
const props = defineProps<{
registry?: string
}>()
const route = useRoute()
const router = useRouter()
const store = usePackageStore()
const { packages, loading, error } = storeToRefs(store)
const showDeleteModal = ref(false)
const packageToDelete = ref<Package | null>(null)
const showVulnerabilityModal = ref(false)
const selectedPackage = ref<{ registry: string; name: string; version: string } | null>(null)
const selectedRegistry = ref<string>('all')
const selectedRegistry = ref<string>(props.registry || 'all')
const searchTerm = ref<string>('')
const currentPage = ref<number>(1)
const itemsPerPage = ref<number>(10)
@@ -359,6 +356,11 @@ watch([selectedRegistry, searchTerm], () => {
resetPagination()
})
// Watch for route parameter changes to update filter
watch(() => route.params.registry, (newRegistry) => {
selectedRegistry.value = (newRegistry as string) || 'all'
})
onMounted(async () => {
await store.fetchPackages()
})
@@ -406,7 +408,19 @@ function formatDate(date: string): string {
}
function showVulnerabilityDetails(registry: string, name: string, version: string) {
selectedPackage.value = { registry, name, version }
showVulnerabilityModal.value = true
// Navigate to the package details page
router.push({
name: 'package-details',
params: {
registry,
name,
version,
},
})
}
function changeRegistryFilter(registry: string) {
const path = registry === 'all' ? '/packages' : `/packages/${registry}`
router.push(path)
}
</script>
@@ -22,15 +22,15 @@
HIGH: {{ counts.high }}
</button>
<!-- Medium Vulnerabilities -->
<!-- Moderate Vulnerabilities -->
<button
v-if="counts.medium > 0"
@click="handleClick('medium')"
v-if="counts.moderate > 0"
@click="handleClick('moderate')"
class="inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium bg-yellow-100 text-yellow-800 hover:bg-yellow-200 border border-yellow-300 transition-colors cursor-pointer"
:title="`${counts.medium} medium severity vulnerabilities - click for details`"
:title="`${counts.moderate} moderate severity vulnerabilities - click for details`"
>
<i class="fas fa-exclamation-circle mr-1"></i>
MEDIUM: {{ counts.medium }}
MODERATE: {{ counts.moderate }}
</button>
<!-- Low Vulnerabilities -->
@@ -101,7 +101,7 @@ const emit = defineEmits<{
click: [severity: string]
}>()
const counts = computed(() => props.counts || { critical: 0, high: 0, medium: 0, low: 0 })
const counts = computed(() => props.counts || { critical: 0, high: 0, moderate: 0, low: 0 })
function handleClick(severity: string) {
emit('click', severity)
@@ -1,316 +0,0 @@
<template>
<Dialog v-model:open="isOpen">
<DialogContent class="max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle class="flex items-center gap-2">
<i class="fas fa-shield-virus text-red-600"></i>
Security Vulnerabilities
</DialogTitle>
<DialogDescription>
{{ packageInfo.registry }}/{{ packageInfo.name }}@{{ packageInfo.version }}
</DialogDescription>
</DialogHeader>
<!-- Loading State -->
<div v-if="loading" class="text-center py-12">
<i class="fas fa-spinner fa-spin text-4xl text-primary-600"></i>
<p class="mt-4 text-gray-600">Loading vulnerability details...</p>
</div>
<!-- Error State -->
<Alert v-else-if="error" variant="destructive" class="mb-4">
<i class="fas fa-exclamation-circle mr-2"></i>
<AlertDescription>{{ error }}</AlertDescription>
</Alert>
<!-- Vulnerability Details -->
<div v-else-if="vulnerabilities" class="space-y-4">
<!-- Summary Card -->
<Card>
<CardContent class="p-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div class="text-center">
<p class="text-2xl font-bold text-red-600">{{ severityCounts.critical }}</p>
<p class="text-sm text-gray-600">Critical</p>
</div>
<div class="text-center">
<p class="text-2xl font-bold text-orange-600">{{ severityCounts.high }}</p>
<p class="text-sm text-gray-600">High</p>
</div>
<div class="text-center">
<p class="text-2xl font-bold text-yellow-600">{{ severityCounts.medium }}</p>
<p class="text-sm text-gray-600">Medium</p>
</div>
<div class="text-center">
<p class="text-2xl font-bold text-blue-600">{{ severityCounts.low }}</p>
<p class="text-sm text-gray-600">Low</p>
</div>
</div>
<Separator class="my-3" />
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-4">
<span class="text-gray-600">
<i class="fas fa-search mr-1"></i>
Scanned: {{ formatDate(vulnerabilities.scanned_at) }}
</span>
<span class="text-gray-600">
<i class="fas fa-cog mr-1"></i>
Scanner: {{ vulnerabilities.scanner }}
</span>
</div>
<div v-if="bypassedCount > 0" class="flex items-center gap-2 text-green-600">
<i class="fas fa-check-circle"></i>
<span>{{ bypassedCount }} bypassed</span>
</div>
</div>
</CardContent>
</Card>
<!-- No Vulnerabilities -->
<div v-if="vulnerabilityList.length === 0" class="text-center py-8 text-gray-500">
<i class="fas fa-check-circle text-6xl text-green-500 mb-4"></i>
<p class="text-xl font-semibold">No Vulnerabilities Found</p>
<p class="mt-2">This package is clean and safe to use</p>
</div>
<!-- Vulnerability List -->
<div v-else class="space-y-3">
<Accordion type="multiple" class="w-full">
<AccordionItem
v-for="(vuln, index) in vulnerabilityList"
:key="vuln.id"
:value="`vuln-${index}`"
:class="getVulnerabilityBorderClass(vuln.severity)"
>
<AccordionTrigger class="px-4 py-3 hover:bg-gray-50">
<div class="flex items-center justify-between w-full pr-4">
<div class="flex items-center gap-3">
<Badge :class="getSeverityBadgeClass(vuln.severity)">
{{ vuln.severity }}
</Badge>
<div class="text-left">
<h4 class="font-semibold text-gray-900">{{ vuln.id }}</h4>
<p class="text-sm text-gray-600">{{ vuln.title }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<span
v-if="vuln.bypassed"
class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800 border border-green-300"
title="This vulnerability is bypassed"
>
<i class="fas fa-unlock mr-1"></i>
BYPASSED
</span>
<span
v-if="vuln.fixed_in"
class="text-xs text-gray-500"
:title="`Fixed in version ${vuln.fixed_in}`"
>
<i class="fas fa-wrench mr-1"></i>
Fix: v{{ vuln.fixed_in }}
</span>
</div>
</div>
</AccordionTrigger>
<AccordionContent class="px-4 pb-4">
<div class="space-y-3">
<!-- Description -->
<div>
<h5 class="font-medium text-gray-900 mb-1">Description</h5>
<p class="text-sm text-gray-700">{{ vuln.description }}</p>
</div>
<!-- Bypass Information -->
<div v-if="vuln.bypassed && vuln.bypass" class="bg-green-50 border border-green-200 rounded-lg p-3">
<h5 class="font-medium text-green-900 mb-2 flex items-center gap-2">
<i class="fas fa-info-circle"></i>
Bypass Information
</h5>
<div class="space-y-1 text-sm text-green-800">
<p><strong>Reason:</strong> {{ vuln.bypass.reason }}</p>
<p><strong>Created by:</strong> {{ vuln.bypass.created_by }}</p>
<p><strong>Expires:</strong> {{ formatDate(vuln.bypass.expires_at) }}</p>
</div>
</div>
<!-- Fixed In -->
<div v-if="vuln.fixed_in">
<h5 class="font-medium text-gray-900 mb-1">Fix Available</h5>
<p class="text-sm text-gray-700">
<i class="fas fa-arrow-up text-green-600 mr-1"></i>
Upgrade to version <strong>{{ vuln.fixed_in }}</strong> or later
</p>
</div>
<!-- References -->
<div v-if="vuln.references && vuln.references.length > 0">
<h5 class="font-medium text-gray-900 mb-1">References</h5>
<ul class="space-y-1">
<li v-for="(ref, i) in vuln.references" :key="i">
<a
:href="ref"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-blue-600 hover:text-blue-800 hover:underline flex items-center gap-1"
>
<i class="fas fa-external-link-alt text-xs"></i>
{{ ref }}
</a>
</li>
</ul>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
<DialogFooter>
<Button @click="isOpen = false" variant="outline">
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import axios from 'axios'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Separator } from '@/components/ui/separator'
interface Props {
open: boolean
registry: string
packageName: string
version: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:open': [value: boolean]
}>()
const isOpen = computed({
get: () => props.open,
set: (value) => emit('update:open', value),
})
interface BypassInfo {
id: string
reason: string
created_by: string
expires_at: string
}
interface Vulnerability {
id: string
severity: string
title: string
description: string
references: string[]
fixed_in: string
bypassed: boolean
bypass?: BypassInfo
}
interface VulnerabilityResponse {
scanned: boolean
scanner: string
scanned_at: string
status: string
vulnerabilities: Vulnerability[]
vulnerability_count: number
severity_counts: {
critical: number
high: number
medium: number
low: number
}
bypassed_count: number
}
const loading = ref(false)
const error = ref<string | null>(null)
const vulnerabilities = ref<VulnerabilityResponse | null>(null)
const packageInfo = computed(() => ({
registry: props.registry,
name: props.packageName,
version: props.version,
}))
const vulnerabilityList = computed(() => vulnerabilities.value?.vulnerabilities || [])
const severityCounts = computed(() => vulnerabilities.value?.severity_counts || { critical: 0, high: 0, medium: 0, low: 0 })
const bypassedCount = computed(() => vulnerabilities.value?.bypassed_count || 0)
// Fetch vulnerabilities when modal opens
watch(() => props.open, async (newValue) => {
if (newValue) {
await fetchVulnerabilities()
}
})
async function fetchVulnerabilities() {
loading.value = true
error.value = null
vulnerabilities.value = null
try {
const response = await axios.get(
`/api/packages/${props.registry}/${props.packageName}/${props.version}/vulnerabilities`
)
// API wraps response in {success: true, data: {...}}
vulnerabilities.value = response.data.data
} catch (err: any) {
console.error('Failed to fetch vulnerabilities:', err)
error.value = err.response?.data?.error?.message || err.message || 'Failed to load vulnerability details'
} finally {
loading.value = false
}
}
function getSeverityBadgeClass(severity: string): string {
const classes: Record<string, string> = {
CRITICAL: 'bg-red-100 text-red-800 border-red-300',
HIGH: 'bg-orange-100 text-orange-800 border-orange-300',
MEDIUM: 'bg-yellow-100 text-yellow-800 border-yellow-300',
LOW: 'bg-blue-100 text-blue-800 border-blue-300',
}
return classes[severity.toUpperCase()] || 'bg-gray-100 text-gray-800 border-gray-300'
}
function getVulnerabilityBorderClass(severity: string): string {
const classes: Record<string, string> = {
CRITICAL: 'border-l-4 border-l-red-500',
HIGH: 'border-l-4 border-l-orange-500',
MEDIUM: 'border-l-4 border-l-yellow-500',
LOW: 'border-l-4 border-l-blue-500',
}
return classes[severity.toUpperCase()] || 'border-l-4 border-l-gray-500'
}
function formatDate(date: string): string {
return new Date(date).toLocaleString()
}
</script>
@@ -0,0 +1,15 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<div class="relative w-full overflow-auto">
<table :class="cn('w-full caption-bottom text-sm', props.class)">
<slot />
</table>
</div>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<tbody :class="cn('[&_tr:last-child]:border-0', props.class)">
<slot />
</tbody>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<td :class="cn('p-4 align-middle [&:has([role=checkbox])]:pr-0', props.class)">
<slot />
</td>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<th :class="cn('h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0', props.class)">
<slot />
</th>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<thead :class="cn('[&_tr]:border-b', props.class)">
<slot />
</thead>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{
class?: string
}>()
</script>
<template>
<tr :class="cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', props.class)">
<slot />
</tr>
</template>
@@ -0,0 +1,6 @@
export { default as Table } from './Table.vue'
export { default as TableHeader } from './TableHeader.vue'
export { default as TableBody } from './TableBody.vue'
export { default as TableRow } from './TableRow.vue'
export { default as TableHead } from './TableHead.vue'
export { default as TableCell } from './TableCell.vue'
+10 -1
View File
@@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from '../components/Dashboard.vue'
import PackageList from '../components/PackageList.vue'
import PackageDetails from '../components/PackageDetails.vue'
import Stats from '../components/Stats.vue'
import BypassManagementPanel from '../components/BypassManagementPanel.vue'
@@ -13,9 +14,17 @@ const router = createRouter({
component: Dashboard,
},
{
path: '/packages',
path: '/packages/:registry?',
name: 'packages',
component: PackageList,
props: true,
},
{
// Separate route for package details - supports names with slashes (Go packages)
path: '/package/:registry/:name+/:version',
name: 'package-details',
component: PackageDetails,
props: true,
},
{
path: '/stats',
+1 -1
View File
@@ -5,7 +5,7 @@ import axios from 'axios'
export interface VulnerabilityCounts {
critical: number
high: number
medium: number
moderate: number
low: number
}