mirror of
https://github.com/lukaszraczylo/gohoarder.git
synced 2026-06-13 02:36:48 +00:00
fixes
This commit is contained in:
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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',
|
||||
|
||||
@@ -5,7 +5,7 @@ import axios from 'axios'
|
||||
export interface VulnerabilityCounts {
|
||||
critical: number
|
||||
high: number
|
||||
medium: number
|
||||
moderate: number
|
||||
low: number
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user