@cryptotaxi247 / CoPilot / commits / bb654aa3

Vuln overview frontend (#504)

* Add Vulnerability Overview feature with API integration and UI components * Add statistics cards and quick stats to Vulnerability List component * Enhance dark mode styles for vulnerability statistics cards and improve overall UI consistency * Add EPSS Package Cards to display top packages by EPSS score with detailed statistics * Enhance styling for statistics cards in dark mode with improved visibility and aesthetics * Enhance EPSS package cards with selection functionality and improved dark mode styles * Enhance dark mode styles for EPSS package cards with improved color consistency * Enhance light mode styles for EPSS package cards with specific background and border colors * Enhance severity cards with selection functionality and improved styles for better user interaction * Enhance VulnerabilitySearchResponse to include severity counts and update related components to utilize these counts * Enhance agent selection functionality by replacing input with a dropdown and loading agents on component mount * Reorder import statements to improve code organization * Enhance vulnerability list component with filters and statistics display * Refactor VulnerabilityCard to include severity-based border styling and remove unused quick stats in List component * Enhance customer selection functionality by adding loading state and fetching customers on component mount * precommit fixes

taylor_socfortress committed Sep 10, 2025 at 14:55 UTC bb654aa3a4f9c5e686279886785c5c5f20532dfc
11 files changed +1678 -1
backend/app/agents/vulnerabilities/schema/vulnerabilities.py
+4
@@ -126,6 +126,10 @@ class VulnerabilitySearchResponse(BaseModel):
126
127 vulnerabilities: List[VulnerabilitySearchItem]
128 total_count: int
129 + critical_count: int
130 + high_count: int
131 + medium_count: int
132 + low_count: int
133 page: int
134 page_size: int
135 total_pages: int
backend/app/agents/vulnerabilities/services/vulnerabilities.py
+44 -1
@@ -898,6 +898,10 @@ async def search_vulnerabilities_from_indexer(
898 return VulnerabilitySearchResponse(
899 vulnerabilities=[],
900 total_count=0,
901 + critical_count=0,
902 + high_count=0,
903 + medium_count=0,
904 + low_count=0,
905 page=page,
906 page_size=page_size,
907 total_pages=0,
@@ -919,6 +923,10 @@ async def search_vulnerabilities_from_indexer(
923 return VulnerabilitySearchResponse(
924 vulnerabilities=[],
925 total_count=0,
926 + critical_count=0,
927 + high_count=0,
928 + medium_count=0,
929 + low_count=0,
930 page=page,
931 page_size=page_size,
932 total_pages=0,
@@ -956,10 +964,29 @@ async def search_vulnerabilities_from_indexer(
964 try:
965 es_client = await create_wazuh_indexer_client_async("Wazuh-Indexer")
966
959 - # First, get total count
967 + # First, get total count and severity aggregations
968 count_response = await es_client.count(index=",".join(vuln_indices), body={"query": es_query})
969 total_count = count_response["count"]
970
971 + # Get severity aggregations
972 + agg_response = await es_client.search(
973 + index=",".join(vuln_indices),
974 + body={
975 + "query": es_query,
976 + "size": 0, # We don't need documents, just aggregations
977 + "aggs": {"severity_counts": {"terms": {"field": "vulnerability.severity", "size": 10}}},
978 + },
979 + )
980 +
981 + # Extract severity counts from aggregation response
982 + severity_counts = {"Critical": 0, "High": 0, "Medium": 0, "Low": 0}
983 + if "aggregations" in agg_response and "severity_counts" in agg_response["aggregations"]:
984 + for bucket in agg_response["aggregations"]["severity_counts"]["buckets"]:
985 + severity = bucket["key"]
986 + count = bucket["doc_count"]
987 + if severity in severity_counts:
988 + severity_counts[severity] = count
989 +
990 # Calculate pagination info
991 total_pages = (total_count + page_size - 1) // page_size
992 has_next = page < total_pages
@@ -969,6 +996,10 @@ async def search_vulnerabilities_from_indexer(
996 return VulnerabilitySearchResponse(
997 vulnerabilities=[],
998 total_count=0,
999 + critical_count=0,
1000 + high_count=0,
1001 + medium_count=0,
1002 + low_count=0,
1003 page=page,
1004 page_size=page_size,
1005 total_pages=0,
@@ -1037,6 +1068,10 @@ async def search_vulnerabilities_from_indexer(
1068 return VulnerabilitySearchResponse(
1069 vulnerabilities=vulnerabilities,
1070 total_count=total_count,
1071 + critical_count=severity_counts["Critical"],
1072 + high_count=severity_counts["High"],
1073 + medium_count=severity_counts["Medium"],
1074 + low_count=severity_counts["Low"],
1075 page=page,
1076 page_size=page_size,
1077 total_pages=total_pages,
@@ -1052,6 +1087,10 @@ async def search_vulnerabilities_from_indexer(
1087 return VulnerabilitySearchResponse(
1088 vulnerabilities=[],
1089 total_count=0,
1090 + critical_count=0,
1091 + high_count=0,
1092 + medium_count=0,
1093 + low_count=0,
1094 page=page,
1095 page_size=page_size,
1096 total_pages=0,
@@ -1074,6 +1113,10 @@ async def search_vulnerabilities_from_indexer(
1113 return VulnerabilitySearchResponse(
1114 vulnerabilities=[],
1115 total_count=0,
1116 + critical_count=0,
1117 + high_count=0,
1118 + medium_count=0,
1119 + low_count=0,
1120 page=page,
1121 page_size=page_size,
1122 total_pages=0,
frontend/src/api/endpoints/vulnerabilities.ts new
+26
@@ -0,0 +1,26 @@
1 +import type {
2 + VulnerabilitySearchQuery,
3 + VulnerabilitySearchResponse
4 +} from "@/types/vulnerabilities.d"
5 +import { HttpClient } from "../httpClient"
6 +
7 +export default {
8 + /**
9 + * Search vulnerabilities directly from Wazuh indexer with filtering and pagination
10 + */
11 + searchVulnerabilities(query?: VulnerabilitySearchQuery, signal?: AbortSignal) {
12 + return HttpClient.get<VulnerabilitySearchResponse>(`/vulnerabilities/search`, {
13 + params: {
14 + customer_code: query?.customer_code,
15 + agent_name: query?.agent_name,
16 + severity: query?.severity,
17 + cve_id: query?.cve_id,
18 + package_name: query?.package_name,
19 + page: query?.page || 1,
20 + page_size: query?.page_size || 50,
21 + include_epss: query?.include_epss !== false
22 + },
23 + signal
24 + })
25 + }
26 +}
frontend/src/api/index.ts
+2
@@ -28,6 +28,7 @@ import stackProvisioning from "./endpoints/stackProvisioning"
28 import sysmonConfig from "./endpoints/sysmonConfig"
29 import threatIntel from "./endpoints/threatIntel"
30 import users from "./endpoints/users"
31 +import vulnerabilities from "./endpoints/vulnerabilities"
32 import wazuh from "./endpoints/wazuh"
33 import indices from "./endpoints/wazuh/indices"
34 import webVulnerabilityAssessment from "./endpoints/webVulnerabilityAssessment"
@@ -62,6 +63,7 @@ export default {
63 sigma,
64 users,
65 sysmonConfig,
66 + vulnerabilities,
67 wazuh,
68 portainer,
69 shuffle,
frontend/src/app-layouts/common/Navbar/items.tsx
+13
@@ -105,6 +105,19 @@ export default function getItems(): MenuMixedOption[] {
105 { default: () => "CoPilot Actions" }
106 ),
107 key: "CopilotActions"
108 + },
109 + {
110 + label: () =>
111 + h(
112 + RouterLink,
113 + {
114 + to: {
115 + name: "VulnerabilityOverview"
116 + }
117 + },
118 + { default: () => "Vulnerability Overview" }
119 + ),
120 + key: "VulnerabilityOverview"
121 }
122 ]
123 },
frontend/src/components/vulnerabilities/List.vue new
+1096
@@ -0,0 +1,1096 @@
1 +<template>
2 + <div class="flex flex-col gap-4">
3 + <!-- Info Banner -->
4 + <div class="info-banner p-3 rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/30">
5 + <div class="flex items-start gap-3">
6 + <Icon :name="InfoIcon" class="text-blue-600 dark:text-blue-400 mt-0.5" :size="16" />
7 + <p class="text-sm text-blue-800 dark:text-blue-200 leading-relaxed">
8 + Vulnerability Overview provides real-time vulnerability data from Wazuh Indexer with EPSS scoring and detailed package information.
9 + </p>
10 + </div>
11 + </div>
12 +
13 + <!-- Filters -->
14 + <div class="flex flex-col">
15 + <div ref="header" class="header flex items-center justify-end gap-2">
16 + <div class="info flex grow gap-2">
17 + <n-popover overlap placement="bottom-start">
18 + <template #trigger>
19 + <div class="bg-default rounded-lg">
20 + <n-button size="small" class="!cursor-help">
21 + <template #icon>
22 + <Icon :name="InfoIcon"></Icon>
23 + </template>
24 + </n-button>
25 + </div>
26 + </template>
27 + <div class="flex flex-col gap-3 p-2 max-w-sm">
28 + <div class="font-medium text-sm mb-2">Vulnerability Overview</div>
29 +
30 + <div class="grid grid-cols-2 gap-3 text-xs">
31 + <div class="flex justify-between">
32 + <span>Total Vulnerabilities:</span>
33 + <code class="font-mono">{{ totalCount.toLocaleString() }}</code>
34 + </div>
35 + <div class="flex justify-between">
36 + <span>Current Page:</span>
37 + <code class="font-mono">{{ currentPage }} / {{ totalPages }}</code>
38 + </div>
39 + </div>
40 +
41 + <div class="border-t pt-2">
42 + <div class="text-xs font-medium mb-2">Severity Distribution</div>
43 + <div class="grid grid-cols-2 gap-2 text-xs">
44 + <div class="flex justify-between">
45 + <span class="text-red-600">Critical:</span>
46 + <span class="font-mono">{{ stats.critical.toLocaleString() }} ({{ getPercentage(stats.critical) }}%)</span>
47 + </div>
48 + <div class="flex justify-between">
49 + <span class="text-orange-600">High:</span>
50 + <span class="font-mono">{{ stats.high.toLocaleString() }} ({{ getPercentage(stats.high) }}%)</span>
51 + </div>
52 + <div class="flex justify-between">
53 + <span class="text-yellow-600">Medium:</span>
54 + <span class="font-mono">{{ stats.medium.toLocaleString() }} ({{ getPercentage(stats.medium) }}%)</span>
55 + </div>
56 + <div class="flex justify-between">
57 + <span class="text-blue-600">Low:</span>
58 + <span class="font-mono">{{ stats.low.toLocaleString() }} ({{ getPercentage(stats.low) }}%)</span>
59 + </div>
60 + </div>
61 + </div>
62 +
63 + <div class="border-t pt-2">
64 + <div class="text-xs font-medium mb-2">Coverage</div>
65 + <div class="space-y-1 text-xs">
66 + <div class="flex justify-between">
67 + <span>Affected Agents:</span>
68 + <span class="font-mono">{{ stats.uniqueAgents.toLocaleString() }}</span>
69 + </div>
70 + <div class="flex justify-between">
71 + <span>Unique Packages:</span>
72 + <span class="font-mono">{{ stats.uniquePackages.toLocaleString() }}</span>
73 + </div>
74 + <div class="flex justify-between">
75 + <span>Customer Codes:</span>
76 + <span class="font-mono">{{ stats.uniqueCustomers.toLocaleString() }}</span>
77 + </div>
78 + </div>
79 + </div>
80 + </div>
81 + </n-popover> <n-select
82 + v-model:value="selectedCustomer"
83 + :options="customerOptions"
84 + clearable
85 + size="small"
86 + placeholder="Customer"
87 + class="max-w-32"
88 + :loading="loadingCustomers"
89 + />
90 +
91 + <n-select
92 + v-model:value="selectedSeverity"
93 + :options="severityOptions"
94 + clearable
95 + size="small"
96 + placeholder="Severity"
97 + class="max-w-32"
98 + />
99 +
100 + <n-input
101 + v-model:value="searchCVE"
102 + size="small"
103 + placeholder="Search CVE..."
104 + class="max-w-40"
105 + clearable
106 + >
107 + <template #prefix>
108 + <Icon :name="SearchIcon"></Icon>
109 + </template>
110 + </n-input>
111 +
112 + <n-select
113 + v-model:value="searchAgent"
114 + :options="agentOptions"
115 + size="small"
116 + placeholder="Search agent..."
117 + class="max-w-40"
118 + clearable
119 + filterable
120 + :loading="loadingAgents"
121 + />
122 +
123 + <n-input
124 + v-model:value="searchPackage"
125 + size="small"
126 + placeholder="Search package..."
127 + class="max-w-40"
128 + clearable
129 + >
130 + <template #prefix>
131 + <Icon :name="PackageIcon"></Icon>
132 + </template>
133 + </n-input>
134 + </div>
135 + </div>
136 + </div>
137 +
138 + <!-- Statistics Cards -->
139 + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5 gap-4 mb-4">
140 + <div class="stat-card">
141 + <div class="stat-header">
142 + <Icon :name="TotalIcon" :size="20" class="text-blue-600" />
143 + <span class="stat-title">Total</span>
144 + </div>
145 + <div class="stat-value">{{ totalCount.toLocaleString() }}</div>
146 + </div>
147 +
148 + <div class="stat-card critical clickable" :class="{ selected: selectedSeverity === VulnerabilitySeverity.Critical }" @click="selectSeverity(VulnerabilitySeverity.Critical)">
149 + <div class="stat-header">
150 + <Icon :name="CriticalIcon" :size="20" class="text-red-600" />
151 + <span class="stat-title">Critical</span>
152 + </div>
153 + <div class="stat-value">{{ stats.critical.toLocaleString() }}</div>
154 + <div class="stat-percentage">{{ getPercentage(stats.critical) }}%</div>
155 + </div>
156 +
157 + <div class="stat-card high clickable" :class="{ selected: selectedSeverity === VulnerabilitySeverity.High }" @click="selectSeverity(VulnerabilitySeverity.High)">
158 + <div class="stat-header">
159 + <Icon :name="HighIcon" :size="20" class="text-orange-600" />
160 + <span class="stat-title">High</span>
161 + </div>
162 + <div class="stat-value">{{ stats.high.toLocaleString() }}</div>
163 + <div class="stat-percentage">{{ getPercentage(stats.high) }}%</div>
164 + </div>
165 +
166 + <div class="stat-card medium clickable" :class="{ selected: selectedSeverity === VulnerabilitySeverity.Medium }" @click="selectSeverity(VulnerabilitySeverity.Medium)">
167 + <div class="stat-header">
168 + <Icon :name="MediumIcon" :size="20" class="text-yellow-600" />
169 + <span class="stat-title">Medium</span>
170 + </div>
171 + <div class="stat-value">{{ stats.medium.toLocaleString() }}</div>
172 + <div class="stat-percentage">{{ getPercentage(stats.medium) }}%</div>
173 + </div>
174 +
175 + <div class="stat-card low clickable" :class="{ selected: selectedSeverity === VulnerabilitySeverity.Low }" @click="selectSeverity(VulnerabilitySeverity.Low)">
176 + <div class="stat-header">
177 + <Icon :name="LowIcon" :size="20" class="text-blue-600" />
178 + <span class="stat-title">Low</span>
179 + </div>
180 + <div class="stat-value">{{ stats.low.toLocaleString() }}</div>
181 + <div class="stat-percentage">{{ getPercentage(stats.low) }}%</div>
182 + </div>
183 + </div>
184 +
185 + <!-- Top 5 Packages by EPSS Score -->
186 + <div v-if="topEpssPackages.length > 0" class="mb-4">
187 + <h3 class="text-lg font-semibold mb-3 text-gray-900 dark:text-gray-100 flex items-center gap-2">
188 + <Icon :name="PackageIcon" :size="20" class="text-orange-600" />
189 + Top 5 Packages by EPSS Score
190 + </h3>
191 + <div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
192 + <div
193 + v-for="(pkg, index) in topEpssPackages.slice(0, 5)"
194 + :key="`${pkg.package_name}-${pkg.maxEpssScore}`"
195 + class="epss-package-card clickable"
196 + :class="{
197 + 'rank-1': index === 0,
198 + 'rank-2': index === 1,
199 + 'rank-3': index === 2,
200 + 'selected': searchPackage === pkg.package_name
201 + }"
202 + @click="selectPackage(pkg.package_name)"
203 + >
204 + <div class="epss-header">
205 + <div class="epss-rank">
206 + <Icon
207 + :name="index < 3 ? 'carbon:trophy' : 'carbon:warning-alt'"
208 + :size="16"
209 + :class="index === 0 ? 'text-yellow-500' : index === 1 ? 'text-gray-400' : index === 2 ? 'text-amber-600' : 'text-orange-500'"
210 + />
211 + <span class="rank-number">#{{ index + 1 }}</span>
212 + </div>
213 + <Badge color="warning" type="splitted" size="small">
214 + <template #label>EPSS</template>
215 + <template #value>{{ pkg.maxEpssScore.toFixed(3) }}</template>
216 + </Badge>
217 + </div>
218 +
219 + <div class="package-name">{{ pkg.package_name }}</div>
220 +
221 + <div class="package-stats">
222 + <div class="stat-row">
223 + <span class="stat-label">Vulnerabilities:</span>
224 + <span class="stat-value">{{ pkg.vulnCount.toLocaleString() }}</span>
225 + </div>
226 + <div class="stat-row">
227 + <span class="stat-label">Affected Agents:</span>
228 + <span class="stat-value">{{ pkg.affectedAgents.toLocaleString() }}</span>
229 + </div>
230 + <div class="stat-row">
231 + <span class="stat-label">Max CVSS:</span>
232 + <span class="stat-value">{{ pkg.maxCvssScore?.toFixed(1) || 'N/A' }}</span>
233 + </div>
234 + </div>
235 +
236 + <!-- Critical/High severity indicator -->
237 + <div v-if="pkg.criticalCount > 0 || pkg.highCount > 0" class="severity-indicator">
238 + <Badge v-if="pkg.criticalCount > 0" color="danger" size="small">
239 + <template #value>{{ pkg.criticalCount }} Critical</template>
240 + </Badge>
241 + <Badge v-if="pkg.highCount > 0" color="warning" size="small">
242 + <template #value>{{ pkg.highCount }} High</template>
243 + </Badge>
244 + </div>
245 + </div>
246 + </div>
247 + </div>
248 +
249 + <!-- Vulnerability List -->
250 + <n-spin :show="loading">
251 + <div class="my-3">
252 + <template v-if="list.length">
253 + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
254 + <VulnerabilityCard v-for="item of list" :key="`${item.cve_id}-${item.agent_name}`" :vulnerability="item" />
255 + </div>
256 +
257 + <!-- Pagination -->
258 + <div class="flex justify-center mt-6">
259 + <n-pagination
260 + v-model:page="currentPage"
261 + :page-count="totalPages"
262 + :page-size="pageSize"
263 + :item-count="totalCount"
264 + show-size-picker
265 + :page-sizes="[25, 50, 100, 200]"
266 + @update:page="updatePage"
267 + @update:page-size="updatePageSize"
268 + />
269 + </div>
270 + </template>
271 + <template v-else>
272 + <n-empty v-if="!loading" description="No vulnerabilities found" class="h-48 justify-center" />
273 + </template>
274 + </div>
275 + </n-spin>
276 + </div>
277 +</template>
278 +
279 +<script setup lang="ts">
280 +import type { Agent } from "@/types/agents.d"
281 +import type { Customer } from "@/types/customers.d"
282 +import type { VulnerabilitySearchItem, VulnerabilitySearchQuery } from "@/types/vulnerabilities.d"
283 +import { watchDebounced } from "@vueuse/core"
284 +import axios from "axios"
285 +import { NButton, NEmpty, NInput, NPagination, NPopover, NSelect, NSpin, useMessage } from "naive-ui"
286 +import { computed, onMounted, ref } from "vue"
287 +import Api from "@/api"
288 +import Icon from "@/components/common/Icon.vue"
289 +import { VulnerabilitySeverity } from "@/types/vulnerabilities.d"
290 +import VulnerabilityCard from "./VulnerabilityCard.vue"
291 +
292 +const loading = ref(false)
293 +const message = useMessage()
294 +const list = ref<VulnerabilitySearchItem[]>([])
295 +const header = ref()
296 +const totalCount = ref(0)
297 +const currentPage = ref(1)
298 +const pageSize = ref(50)
299 +const totalPages = ref(0)
300 +const selectedCustomer = ref<string | null>(null)
301 +const selectedSeverity = ref<VulnerabilitySeverity | null>(null)
302 +const searchCVE = ref<string>("")
303 +const searchAgent = ref<string>("")
304 +const searchPackage = ref<string>("")
305 +
306 +// Severity counts from API response
307 +const criticalCount = ref(0)
308 +const highCount = ref(0)
309 +const mediumCount = ref(0)
310 +const lowCount = ref(0)
311 +
312 +// Agents data for dropdown
313 +const agents = ref<Agent[]>([])
314 +const loadingAgents = ref(false)
315 +
316 +// Customers data for dropdown
317 +const customers = ref<Customer[]>([])
318 +const loadingCustomers = ref(false)
319 +
320 +const InfoIcon = "carbon:information"
321 +const SearchIcon = "carbon:search"
322 +const PackageIcon = "carbon:package"
323 +const TotalIcon = "carbon:result"
324 +const CriticalIcon = "carbon:warning-filled"
325 +const HighIcon = "carbon:warning"
326 +const MediumIcon = "carbon:warning-alt"
327 +const LowIcon = "carbon:information"
328 +
329 +const severityOptions = Object.values(VulnerabilitySeverity).map(severity => ({
330 + label: severity,
331 + value: severity
332 +}))
333 +
334 +// Agent options for dropdown
335 +const agentOptions = computed(() => {
336 + return (agents.value || []).map(agent => ({
337 + label: agent.hostname,
338 + value: agent.hostname
339 + }))
340 +})
341 +
342 +// Calculate statistics from current data
343 +const stats = computed(() => {
344 + // Use API response counts for global statistics across all pages
345 + const critical = criticalCount.value
346 + const high = highCount.value
347 + const medium = mediumCount.value
348 + const low = lowCount.value
349 +
350 + // Calculate unique values from current page data for context
351 + const uniqueAgents = new Set(list.value.map(v => v.agent_name)).size
352 + const uniquePackages = new Set(list.value.map(v => v.package_name).filter(Boolean)).size
353 + const uniqueCustomers = new Set(list.value.map(v => v.customer_code).filter(Boolean)).size
354 +
355 + return {
356 + critical,
357 + high,
358 + medium,
359 + low,
360 + uniqueAgents,
361 + uniquePackages,
362 + uniqueCustomers
363 + }
364 +})
365 +
366 +// Calculate top packages by EPSS score
367 +const topEpssPackages = computed(() => {
368 + // Group vulnerabilities by package name
369 + const packageMap = new Map<string, {
370 + package_name: string
371 + vulnCount: number
372 + maxEpssScore: number
373 + maxCvssScore: number | null
374 + affectedAgents: Set<string>
375 + criticalCount: number
376 + highCount: number
377 + }>()
378 +
379 + list.value.forEach(vuln => {
380 + if (!vuln.package_name || !vuln.epss_score) return
381 +
382 + const epssScore = Number.parseFloat(vuln.epss_score)
383 + if (Number.isNaN(epssScore)) return
384 +
385 + const key = vuln.package_name
386 + const existing = packageMap.get(key)
387 +
388 + if (existing) {
389 + existing.vulnCount++
390 + existing.maxEpssScore = Math.max(existing.maxEpssScore, epssScore)
391 + if (vuln.base_score) {
392 + existing.maxCvssScore = Math.max(existing.maxCvssScore || 0, vuln.base_score)
393 + }
394 + existing.affectedAgents.add(vuln.agent_name)
395 +
396 + if (vuln.severity === VulnerabilitySeverity.Critical) existing.criticalCount++
397 + if (vuln.severity === VulnerabilitySeverity.High) existing.highCount++
398 + } else {
399 + packageMap.set(key, {
400 + package_name: vuln.package_name,
401 + vulnCount: 1,
402 + maxEpssScore: epssScore,
403 + maxCvssScore: vuln.base_score || null,
404 + affectedAgents: new Set([vuln.agent_name]),
405 + criticalCount: vuln.severity === VulnerabilitySeverity.Critical ? 1 : 0,
406 + highCount: vuln.severity === VulnerabilitySeverity.High ? 1 : 0
407 + })
408 + }
409 + })
410 +
411 + // Convert to array and sort by max EPSS score
412 + return Array.from(packageMap.values())
413 + .map(pkg => ({
414 + ...pkg,
415 + affectedAgents: pkg.affectedAgents.size
416 + }))
417 + .sort((a, b) => b.maxEpssScore - a.maxEpssScore)
418 + .slice(0, 5)
419 +})
420 +
421 +function getPercentage(count: number): string {
422 + if (totalCount.value === 0) return "0"
423 + return ((count / totalCount.value) * 100).toFixed(1)
424 +}
425 +
426 +// Customer options for dropdown
427 +const customerOptions = computed(() => {
428 + return (customers.value || []).map(customer => ({
429 + label: customer.customer_code,
430 + value: customer.customer_code
431 + }))
432 +})
433 +
434 +let abortController: AbortController | null = null
435 +
436 +function getList() {
437 + abortController?.abort()
438 + abortController = new AbortController()
439 +
440 + loading.value = true
441 +
442 + const query: VulnerabilitySearchQuery = {
443 + page: currentPage.value,
444 + page_size: pageSize.value,
445 + customer_code: selectedCustomer.value || undefined,
446 + severity: selectedSeverity.value || undefined,
447 + cve_id: searchCVE.value || undefined,
448 + agent_name: searchAgent.value || undefined,
449 + package_name: searchPackage.value || undefined,
450 + include_epss: true
451 + }
452 +
453 + Api.vulnerabilities
454 + .searchVulnerabilities(query, abortController.signal)
455 + .then(res => {
456 + loading.value = false
457 +
458 + if (res.data.success) {
459 + list.value = res.data?.vulnerabilities || []
460 + totalCount.value = res.data?.total_count || 0
461 + totalPages.value = res.data?.total_pages || 0
462 + currentPage.value = res.data?.page || 1
463 +
464 + // Store severity counts from API response
465 + criticalCount.value = res.data?.critical_count || 0
466 + highCount.value = res.data?.high_count || 0
467 + mediumCount.value = res.data?.medium_count || 0
468 + lowCount.value = res.data?.low_count || 0
469 + } else {
470 + message.warning(res.data?.message || "An error occurred. Please try again later.")
471 + }
472 + })
473 + .catch(err => {
474 + if (!axios.isCancel(err)) {
475 + message.error(err.response?.data?.message || "An error occurred. Please try again later.")
476 + loading.value = false
477 + }
478 + })
479 +}
480 +
481 +function getAgents() {
482 + loadingAgents.value = true
483 +
484 + Api.agents
485 + .getAgents()
486 + .then(res => {
487 + if (res.data.success) {
488 + agents.value = res.data.agents || []
489 + } else {
490 + message.warning(res.data?.message || "Failed to load agents.")
491 + }
492 + })
493 + .catch(err => {
494 + message.error(err.response?.data?.message || "Failed to load agents.")
495 + })
496 + .finally(() => {
497 + loadingAgents.value = false
498 + })
499 +}
500 +
501 +function getCustomers() {
502 + loadingCustomers.value = true
503 +
504 + Api.customers
505 + .getCustomers()
506 + .then(res => {
507 + if (res.data.success) {
508 + customers.value = res.data.customers || []
509 + } else {
510 + message.warning(res.data?.message || "Failed to load customers.")
511 + }
512 + })
513 + .catch(err => {
514 + message.error(err.response?.data?.message || "Failed to load customers.")
515 + })
516 + .finally(() => {
517 + loadingCustomers.value = false
518 + })
519 +}
520 +
521 +function updatePage(page: number) {
522 + currentPage.value = page
523 + getList()
524 +}
525 +
526 +function updatePageSize(size: number) {
527 + pageSize.value = size
528 + currentPage.value = 1
529 + getList()
530 +}
531 +
532 +function selectPackage(packageName: string) {
533 + // If the same package is already selected, clear the filter
534 + if (searchPackage.value === packageName) {
535 + searchPackage.value = ""
536 + } else {
537 + // Set the package name in the search filter
538 + searchPackage.value = packageName
539 + }
540 + // Reset to first page when filtering
541 + currentPage.value = 1
542 +}
543 +
544 +function selectSeverity(severity: VulnerabilitySeverity) {
545 + // If the same severity is already selected, clear the filter
546 + if (selectedSeverity.value === severity) {
547 + selectedSeverity.value = null
548 + } else {
549 + // Set the severity in the search filter
550 + selectedSeverity.value = severity
551 + }
552 + // Reset to first page when filtering
553 + currentPage.value = 1
554 +}
555 +
556 +// Load agents and customers when component mounts
557 +onMounted(() => {
558 + getAgents()
559 + getCustomers()
560 +})
561 +
562 +watchDebounced([selectedCustomer, selectedSeverity, searchCVE, searchAgent, searchPackage], () => {
563 + currentPage.value = 1
564 + getList()
565 +}, {
566 + deep: true,
567 + debounce: 300,
568 + immediate: true
569 +})
570 +</script>
571 +
572 +<style scoped>
573 +.stat-card {
574 + background-color: white;
575 + border-radius: 0.5rem;
576 + padding: 1rem;
577 + border: 1px solid rgb(229 231 235);
578 + box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
579 + transition: all 0.2s ease;
580 +}
581 +
582 +.stat-card.clickable {
583 + cursor: pointer;
584 +}
585 +
586 +.stat-card:hover {
587 + transform: translateY(-2px);
588 + box-shadow: 0 4px 8px 0 rgb(0 0 0 / 0.1);
589 +}
590 +
591 +.stat-card.selected {
592 + border-width: 2px;
593 + box-shadow: 0 4px 12px 0 rgb(59 130 246 / 0.3);
594 +}
595 +
596 +.stat-card.critical {
597 + border-color: rgb(254 202 202);
598 + background-color: rgb(254 242 242);
599 +}
600 +
601 +.stat-card.critical.selected {
602 + border-color: rgb(220 38 38);
603 + background-color: rgb(254 226 226);
604 +}
605 +
606 +.stat-card.high {
607 + border-color: rgb(254 215 170);
608 + background-color: rgb(255 247 237);
609 +}
610 +
611 +.stat-card.high.selected {
612 + border-color: rgb(234 88 12);
613 + background-color: rgb(255 237 213);
614 +}
615 +
616 +.stat-card.medium {
617 + border-color: rgb(254 240 138);
618 + background-color: rgb(254 252 232);
619 +}
620 +
621 +.stat-card.medium.selected {
622 + border-color: rgb(202 138 4);
623 + background-color: rgb(254 249 195);
624 +}
625 +
626 +.stat-card.low {
627 + border-color: rgb(191 219 254);
628 + background-color: rgb(239 246 255);
629 +}
630 +
631 +.stat-card.low.selected {
632 + border-color: rgb(59 130 246);
633 + background-color: rgb(219 234 254);
634 +}
635 +
636 +.stat-header {
637 + display: flex;
638 + align-items: center;
639 + gap: 0.5rem;
640 + margin-bottom: 0.5rem;
641 +}
642 +
643 +.stat-title {
644 + font-size: 0.875rem;
645 + font-weight: 500;
646 + color: rgb(75 85 99);
647 +}
648 +
649 +.stat-value {
650 + font-size: 1.5rem;
651 + font-weight: 700;
652 + color: rgb(17 24 39);
653 +}
654 +
655 +.stat-percentage {
656 + font-size: 0.75rem;
657 + color: rgb(107 114 128);
658 + margin-top: 0.25rem;
659 +}
660 +
661 +.quick-stat {
662 + display: flex;
663 + align-items: center;
664 + gap: 0.5rem;
665 + padding: 0.75rem;
666 + background-color: rgb(249 250 251);
667 + border-radius: 0.5rem;
668 +}
669 +
670 +/* EPSS Package Cards */
671 +.epss-package-card {
672 + background-color: white;
673 + border: 1px solid rgb(229 231 235);
674 + border-radius: 0.5rem;
675 + padding: 1rem;
676 + transition: all 0.2s ease;
677 +}
678 +
679 +/* Light mode specific styles */
680 +:root .epss-package-card,
681 +html:not(.dark) .epss-package-card,
682 +[data-theme="light"] .epss-package-card {
683 + background-color: white;
684 + border-color: rgb(229 231 235);
685 +}
686 +
687 +.epss-package-card.clickable {
688 + cursor: pointer;
689 +}
690 +
691 +.epss-package-card:hover {
692 + border-color: rgb(156 163 175);
693 + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
694 + transform: translateY(-2px);
695 +}
696 +
697 +.epss-package-card.selected {
698 + border-color: rgb(59 130 246);
699 + background-color: rgb(239 246 255);
700 + box-shadow: 0 4px 12px -1px rgb(59 130 246 / 0.2);
701 +}
702 +
703 +.epss-package-card.rank-1 {
704 + border-color: rgb(234 179 8);
705 + background-color: rgb(255 255 255);
706 +}
707 +
708 +.epss-package-card.rank-2 {
709 + border-color: rgb(156 163 175);
710 + background-color: rgb(255 255 255);
711 +}
712 +
713 +.epss-package-card.rank-3 {
714 + border-color: rgb(217 119 6);
715 + background-color: rgb(255 255 255);
716 +}
717 +
718 +/* Light mode ranked cards */
719 +:root .epss-package-card.rank-1,
720 +html:not(.dark) .epss-package-card.rank-1,
721 +[data-theme="light"] .epss-package-card.rank-1 {
722 + border-color: rgb(234 179 8);
723 + background-color: white;
724 +}
725 +
726 +:root .epss-package-card.rank-2,
727 +html:not(.dark) .epss-package-card.rank-2,
728 +[data-theme="light"] .epss-package-card.rank-2 {
729 + border-color: rgb(156 163 175);
730 + background-color: white;
731 +}
732 +
733 +:root .epss-package-card.rank-3,
734 +html:not(.dark) .epss-package-card.rank-3,
735 +[data-theme="light"] .epss-package-card.rank-3 {
736 + border-color: rgb(217 119 6);
737 + background-color: white;
738 +}
739 +
740 +.epss-header {
741 + display: flex;
742 + justify-content: space-between;
743 + align-items: center;
744 + margin-bottom: 0.75rem;
745 +}
746 +
747 +.epss-rank {
748 + display: flex;
749 + align-items: center;
750 + gap: 0.5rem;
751 +}
752 +
753 +.rank-number {
754 + font-weight: 600;
755 + font-size: 0.875rem;
756 + color: rgb(75 85 99);
757 +}
758 +
759 +.package-name {
760 + font-weight: 600;
761 + font-size: 1rem;
762 + color: rgb(17 24 39);
763 + margin-bottom: 0.75rem;
764 + font-family: ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
765 +}
766 +
767 +.package-stats {
768 + display: flex;
769 + flex-direction: column;
770 + gap: 0.5rem;
771 + margin-bottom: 0.75rem;
772 +}
773 +
774 +.stat-row {
775 + display: flex;
776 + justify-content: space-between;
777 + align-items: center;
778 +}
779 +
780 +.stat-label {
781 + font-size: 0.75rem;
782 + color: rgb(107 114 128);
783 + font-weight: 500;
784 +}
785 +
786 +.stat-value {
787 + font-size: 1rem;
788 + font-weight: 800;
789 + color: rgb(255 255 255);
790 + background-color: rgb(59 130 246);
791 + padding: 0.375rem 0.75rem;
792 + border-radius: 0.5rem;
793 + font-family: ui-monospace, SFMono-Regular, "SF Mono", Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
794 + text-align: center;
795 + min-width: 3rem;
796 + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
797 +}
798 +
799 +.severity-indicator {
800 + display: flex;
801 + gap: 0.5rem;
802 + flex-wrap: wrap;
803 +}
804 +
805 +/* Dark mode styles */
806 +html.dark .stat-card {
807 + background-color: rgb(31 41 55);
808 + border-color: rgb(75 85 99);
809 +}
810 +
811 +html.dark .stat-card.critical {
812 + border-color: rgb(220 38 38);
813 + background-color: rgb(127 29 29);
814 +}
815 +
816 +html.dark .stat-card.high {
817 + border-color: rgb(234 88 12);
818 + background-color: rgb(154 52 18);
819 +}
820 +
821 +html.dark .stat-card.medium {
822 + border-color: rgb(202 138 4);
823 + background-color: rgb(161 98 7);
824 +}
825 +
826 +html.dark .stat-card.low {
827 + border-color: rgb(59 130 246);
828 + background-color: rgb(30 64 175);
829 +}
830 +
831 +html.dark .stat-title {
832 + color: rgb(209 213 219);
833 +}
834 +
835 +html.dark .stat-value {
836 + color: rgb(255 255 255);
837 +}
838 +
839 +html.dark .stat-percentage {
840 + color: rgb(209 213 219);
841 +}
842 +
843 +html.dark .quick-stat {
844 + background-color: rgb(31 41 55);
845 + color: rgb(243 244 246);
846 +}
847 +
848 +/* Dark mode for EPSS Package Cards */
849 +html.dark .epss-package-card {
850 + background-color: rgb(31 41 55) !important;
851 + border-color: rgb(75 85 99);
852 +}
853 +
854 +html.dark .epss-package-card:hover {
855 + border-color: rgb(156 163 175);
856 +}
857 +
858 +html.dark .epss-package-card.rank-1 {
859 + border-color: rgb(234 179 8);
860 + background-color: rgb(31 41 55) !important;
861 +}
862 +
863 +html.dark .epss-package-card.rank-2 {
864 + border-color: rgb(156 163 175);
865 + background-color: rgb(31 41 55) !important;
866 +}
867 +
868 +html.dark .epss-package-card.rank-3 {
869 + border-color: rgb(217 119 6);
870 + background-color: rgb(31 41 55) !important;
871 +}
872 +
873 +html.dark .rank-number {
874 + color: rgb(209 213 219);
875 +}
876 +
877 +html.dark .package-name {
878 + color: rgb(243 244 246);
879 +}
880 +
881 +html.dark .stat-label {
882 + color: rgb(156 163 175);
883 +}
884 +
885 +html.dark .stat-value {
886 + color: rgb(255 255 255);
887 + background-color: rgb(79 70 229);
888 + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
889 +}
890 +
891 +/* Alternative dark mode selectors for better compatibility */
892 +.dark .stat-card,
893 +[data-theme="dark"] .stat-card {
894 + background-color: rgb(31 41 55);
895 + border-color: rgb(75 85 99);
896 +}
897 +
898 +.dark .stat-card.critical,
899 +[data-theme="dark"] .stat-card.critical {
900 + border-color: rgb(220 38 38);
901 + background-color: rgb(127 29 29);
902 +}
903 +
904 +.dark .stat-card.critical.selected,
905 +[data-theme="dark"] .stat-card.critical.selected {
906 + border-color: rgb(248 113 113);
907 + background-color: rgb(153 27 27);
908 + box-shadow: 0 4px 12px 0 rgb(248 113 113 / 0.3);
909 +}
910 +
911 +.dark .stat-card.high,
912 +[data-theme="dark"] .stat-card.high {
913 + border-color: rgb(234 88 12);
914 + background-color: rgb(154 52 18);
915 +}
916 +
917 +.dark .stat-card.high.selected,
918 +[data-theme="dark"] .stat-card.high.selected {
919 + border-color: rgb(251 146 60);
920 + background-color: rgb(194 65 14);
921 + box-shadow: 0 4px 12px 0 rgb(251 146 60 / 0.3);
922 +}
923 +
924 +.dark .stat-card.medium,
925 +[data-theme="dark"] .stat-card.medium {
926 + border-color: rgb(202 138 4);
927 + background-color: rgb(161 98 7);
928 +}
929 +
930 +.dark .stat-card.medium.selected,
931 +[data-theme="dark"] .stat-card.medium.selected {
932 + border-color: rgb(250 204 21);
933 + background-color: rgb(180 83 9);
934 + box-shadow: 0 4px 12px 0 rgb(250 204 21 / 0.3);
935 +}
936 +
937 +.dark .stat-card.low,
938 +[data-theme="dark"] .stat-card.low {
939 + border-color: rgb(59 130 246);
940 + background-color: rgb(30 64 175);
941 +}
942 +
943 +.dark .stat-card.low.selected,
944 +[data-theme="dark"] .stat-card.low.selected {
945 + border-color: rgb(96 165 250);
946 + background-color: rgb(37 99 235);
947 + box-shadow: 0 4px 12px 0 rgb(96 165 250 / 0.3);
948 +}
949 +
950 +.dark .stat-title,
951 +[data-theme="dark"] .stat-title {
952 + color: rgb(209 213 219);
953 +}
954 +
955 +.dark .stat-value,
956 +[data-theme="dark"] .stat-value {
957 + color: rgb(255 255 255);
958 +}
959 +
960 +.dark .stat-percentage,
961 +[data-theme="dark"] .stat-percentage {
962 + color: rgb(209 213 219);
963 +}
964 +
965 +.dark .quick-stat,
966 +[data-theme="dark"] .quick-stat {
967 + background-color: rgb(31 41 55);
968 + color: rgb(243 244 246);
969 +}
970 +
971 +/* EPSS Package Cards - Alternative dark mode selectors */
972 +.dark .epss-package-card,
973 +[data-theme="dark"] .epss-package-card {
974 + background-color: rgb(31 41 55) !important;
975 + border-color: rgb(75 85 99);
976 +}
977 +
978 +.dark .epss-package-card.rank-1,
979 +[data-theme="dark"] .epss-package-card.rank-1 {
980 + border-color: rgb(234 179 8);
981 + background-color: rgb(31 41 55) !important;
982 +}
983 +
984 +.dark .epss-package-card.rank-2,
985 +[data-theme="dark"] .epss-package-card.rank-2 {
986 + border-color: rgb(156 163 175);
987 + background-color: rgb(31 41 55) !important;
988 +}
989 +
990 +.dark .epss-package-card.rank-3,
991 +[data-theme="dark"] .epss-package-card.rank-3 {
992 + border-color: rgb(217 119 6);
993 + background-color: rgb(31 41 55) !important;
994 +}
995 +
996 +.dark .epss-package-card:hover,
997 +[data-theme="dark"] .epss-package-card:hover {
998 + border-color: rgb(156 163 175);
999 + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3);
1000 +}
1001 +
1002 +.dark .epss-package-card.selected,
1003 +[data-theme="dark"] .epss-package-card.selected {
1004 + border-color: rgb(96 165 250);
1005 + background-color: rgb(30 58 138);
1006 + box-shadow: 0 4px 12px -1px rgb(96 165 250 / 0.3);
1007 +}
1008 +
1009 +.dark .package-name,
1010 +[data-theme="dark"] .package-name {
1011 + color: rgb(243 244 246);
1012 +}
1013 +
1014 +.dark .stat-label,
1015 +[data-theme="dark"] .stat-label {
1016 + color: rgb(156 163 175);
1017 +}
1018 +
1019 +.dark .stat-value,
1020 +[data-theme="dark"] .stat-value {
1021 + color: rgb(255 255 255);
1022 + background-color: rgb(79 70 229);
1023 + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
1024 +}
1025 +
1026 +/* Media query for system dark mode preference */
1027 +@media (prefers-color-scheme: dark) {
1028 + .stat-card {
1029 + background-color: rgb(31 41 55);
1030 + border-color: rgb(75 85 99);
1031 + }
1032 +
1033 + .stat-card.critical {
1034 + border-color: rgb(220 38 38);
1035 + background-color: rgb(127 29 29);
1036 + }
1037 +
1038 + .stat-card.high {
1039 + border-color: rgb(234 88 12);
1040 + background-color: rgb(154 52 18);
1041 + }
1042 +
1043 + .stat-card.medium {
1044 + border-color: rgb(202 138 4);
1045 + background-color: rgb(161 98 7);
1046 + }
1047 +
1048 + .stat-card.low {
1049 + border-color: rgb(59 130 246);
1050 + background-color: rgb(30 64 175);
1051 + }
1052 +
1053 + .stat-title {
1054 + color: rgb(209 213 219);
1055 + }
1056 +
1057 + .stat-value {
1058 + color: rgb(255 255 255);
1059 + background-color: rgb(79 70 229);
1060 + box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3);
1061 + }
1062 +
1063 + .stat-percentage {
1064 + color: rgb(209 213 219);
1065 + }
1066 +
1067 + .quick-stat {
1068 + background-color: rgb(31 41 55);
1069 + color: rgb(243 244 246);
1070 + }
1071 +
1072 + .epss-package-card {
1073 + background-color: rgb(31 41 55) !important;
1074 + border-color: rgb(75 85 99);
1075 + }
1076 +
1077 + .epss-package-card:hover {
1078 + border-color: rgb(156 163 175);
1079 + box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3);
1080 + }
1081 +
1082 + .epss-package-card.selected {
1083 + border-color: rgb(96 165 250);
1084 + background-color: rgb(30 58 138);
1085 + box-shadow: 0 4px 12px -1px rgb(96 165 250 / 0.3);
1086 + }
1087 +
1088 + .package-name {
1089 + color: rgb(243 244 246);
1090 + }
1091 +
1092 + .stat-label {
1093 + color: rgb(156 163 175);
1094 + }
1095 +}
1096 +</style>
frontend/src/components/vulnerabilities/VulnerabilityCard.vue new
+137
@@ -0,0 +1,137 @@
1 +<template>
2 + <div class="vulnerability-card h-full">
3 + <CardEntity hoverable clickable :embedded class="@container h-full flex flex-col" :class="getSeverityBorderClass(vulnerability.severity)" @click.stop="showDetails = true">
4 + <template #headerMain>{{ vulnerability.cve_id }}</template>
5 + <template #headerExtra>
6 + <Badge :color="getSeverityColor(vulnerability.severity)">
7 + <template #iconLeft><Icon :name="getSeverityIcon(vulnerability.severity)" :size="14" /></template>
8 + <template #value>{{ vulnerability.severity }}</template>
9 + </Badge>
10 + </template>
11 + <template #default>
12 + <div class="flex-1">
13 + <p class="text-base font-medium opacity-90 leading-relaxed line-clamp-3">{{ vulnerability.title }}</p>
14 + <div class="mt-2 text-sm opacity-75">
15 + <div class="flex items-center gap-2">
16 + <Icon :name="HostIcon" :size="14" />
17 + <span>{{ vulnerability.agent_name }}</span>
18 + </div>
19 + <div v-if="vulnerability.package_name" class="flex items-center gap-2 mt-1">
20 + <Icon :name="PackageIcon" :size="14" />
21 + <span>{{ vulnerability.package_name }}{{ vulnerability.package_version ? ` (${vulnerability.package_version})` : '' }}</span>
22 + </div>
23 + </div>
24 + </div>
25 + </template>
26 + <template #footerMain>
27 + <div class="flex flex-wrap items-center gap-2">
28 + <Badge v-if="vulnerability.customer_code" class="text-xs">
29 + <template #value>{{ vulnerability.customer_code }}</template>
30 + </Badge>
31 +
32 + <Badge v-if="vulnerability.base_score" color="primary" type="splitted" class="text-xs">
33 + <template #label>CVSS</template>
34 + <template #value>{{ vulnerability.base_score }}</template>
35 + </Badge>
36 +
37 + <Badge v-if="vulnerability.epss_score" color="warning" type="splitted" class="text-xs">
38 + <template #label>EPSS</template>
39 + <template #value>{{ parseFloat(vulnerability.epss_score).toFixed(3) }}</template>
40 + </Badge>
41 +
42 + <Badge v-if="vulnerability.epss_percentile" color="warning" type="splitted" class="text-xs">
43 + <template #label>EPSS Pct</template>
44 + <template #value>{{ parseFloat(vulnerability.epss_percentile).toFixed(1) }}%</template>
45 + </Badge>
46 +
47 + <Badge v-if="vulnerability.package_architecture" size="small" class="text-xs">
48 + <template #value>{{ vulnerability.package_architecture }}</template>
49 + </Badge>
50 + </div>
51 + </template>
52 + <template #footerExtra>
53 + <div class="text-xs opacity-60">
54 + {{ formatDate(vulnerability.detected_at) }}
55 + </div>
56 + </template>
57 + </CardEntity>
58 +
59 + <!-- Vulnerability Details Modal -->
60 + <n-modal
61 + v-model:show="showDetails"
62 + preset="card"
63 + :style="{ maxWidth: 'min(800px, 90vw)', minHeight: 'min(600px, 90vh)' }"
64 + :title="`Vulnerability: ${vulnerability.cve_id}`"
65 + :bordered="false"
66 + segmented
67 + >
68 + <VulnerabilityCardContent :vulnerability="vulnerability" />
69 + </n-modal>
70 + </div>
71 +</template>
72 +
73 +<script setup lang="ts">
74 +import type { VulnerabilitySearchItem } from "@/types/vulnerabilities.d"
75 +import { NModal } from "naive-ui"
76 +import { ref } from "vue"
77 +import Badge from "@/components/common/Badge.vue"
78 +import CardEntity from "@/components/common/cards/CardEntity.vue"
79 +import Icon from "@/components/common/Icon.vue"
80 +import { VulnerabilitySeverity } from "@/types/vulnerabilities.d"
81 +import VulnerabilityCardContent from "./VulnerabilityCardContent.vue"
82 +
83 +const { vulnerability } = defineProps<{ vulnerability: VulnerabilitySearchItem; embedded?: boolean }>()
84 +
85 +const showDetails = ref(false)
86 +const HostIcon = "carbon:bare-metal-server"
87 +const PackageIcon = "carbon:package"
88 +
89 +function getSeverityIcon(severity: string): string {
90 + const iconMap: Record<string, string> = {
91 + [VulnerabilitySeverity.Critical]: "carbon:warning-filled",
92 + [VulnerabilitySeverity.High]: "carbon:warning",
93 + [VulnerabilitySeverity.Medium]: "carbon:warning-alt",
94 + [VulnerabilitySeverity.Low]: "carbon:information"
95 + }
96 + return iconMap[severity] || "carbon:help"
97 +}
98 +
99 +function getSeverityColor(severity: string): "primary" | "warning" | "success" | "danger" | undefined {
100 + const colorMap: Record<string, "primary" | "warning" | "success" | "danger"> = {
101 + [VulnerabilitySeverity.Critical]: "danger",
102 + [VulnerabilitySeverity.High]: "warning",
103 + [VulnerabilitySeverity.Medium]: "warning",
104 + [VulnerabilitySeverity.Low]: "primary"
105 + }
106 + return colorMap[severity]
107 +}
108 +
109 +function getSeverityBorderClass(severity: string): string {
110 + const borderMap: Record<string, string> = {
111 + [VulnerabilitySeverity.Critical]: "border-l-4 border-l-red-500 dark:border-l-red-400",
112 + [VulnerabilitySeverity.High]: "border-l-4 border-l-orange-500 dark:border-l-orange-400",
113 + [VulnerabilitySeverity.Medium]: "border-l-4 border-l-yellow-500 dark:border-l-yellow-400",
114 + [VulnerabilitySeverity.Low]: "border-l-4 border-l-blue-500 dark:border-l-blue-400"
115 + }
116 + return borderMap[severity] || ""
117 +}
118 +
119 +function formatDate(dateString: string): string {
120 + return new Date(dateString).toLocaleDateString()
121 +}
122 +</script>
123 +
124 +<style scoped>
125 +.vulnerability-card {
126 + min-height: 280px;
127 +}
128 +
129 +.line-clamp-3 {
130 + display: -webkit-box;
131 + -webkit-line-clamp: 3;
132 + line-clamp: 3;
133 + -webkit-box-orient: vertical;
134 + overflow: hidden;
135 + text-overflow: ellipsis;
136 +}
137 +</style>
frontend/src/components/vulnerabilities/VulnerabilityCardContent.vue new
+290
@@ -0,0 +1,290 @@
1 +<template>
2 + <div class="vulnerability-details">
3 + <n-scrollbar class="pr-2">
4 + <div class="flex flex-col gap-6">
5 + <!-- Basic Information -->
6 + <div class="section">
7 + <h3 class="section-title">Basic Information</h3>
8 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
9 + <div class="detail-item">
10 + <label>CVE ID</label>
11 + <div class="value font-mono">{{ vulnerability.cve_id }}</div>
12 + </div>
13 + <div class="detail-item">
14 + <label>Severity</label>
15 + <Badge :color="getSeverityColor(vulnerability.severity)">
16 + <template #iconLeft><Icon :name="getSeverityIcon(vulnerability.severity)" :size="14" /></template>
17 + <template #value>{{ vulnerability.severity }}</template>
18 + </Badge>
19 + </div>
20 + <div class="detail-item">
21 + <label>Agent</label>
22 + <div class="value">{{ vulnerability.agent_name }}</div>
23 + </div>
24 + <div v-if="vulnerability.customer_code" class="detail-item">
25 + <label>Customer Code</label>
26 + <div class="value">{{ vulnerability.customer_code }}</div>
27 + </div>
28 + </div>
29 + </div>
30 +
31 + <!-- Description -->
32 + <div class="section">
33 + <h3 class="section-title">Description</h3>
34 + <div class="value">{{ vulnerability.title }}</div>
35 + </div>
36 +
37 + <!-- Package Information -->
38 + <div v-if="vulnerability.package_name" class="section">
39 + <h3 class="section-title">Package Information</h3>
40 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
41 + <div class="detail-item">
42 + <label>Package Name</label>
43 + <div class="value font-mono">{{ vulnerability.package_name }}</div>
44 + </div>
45 + <div v-if="vulnerability.package_version" class="detail-item">
46 + <label>Version</label>
47 + <div class="value font-mono">{{ vulnerability.package_version }}</div>
48 + </div>
49 + <div v-if="vulnerability.package_architecture" class="detail-item">
50 + <label>Architecture</label>
51 + <div class="value">{{ vulnerability.package_architecture }}</div>
52 + </div>
53 + </div>
54 + </div>
55 +
56 + <!-- Scoring Information -->
57 + <div class="section">
58 + <h3 class="section-title">Scoring</h3>
59 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
60 + <div v-if="vulnerability.base_score" class="detail-item">
61 + <label>CVSS Base Score</label>
62 + <Badge color="primary" type="splitted">
63 + <template #label>Score</template>
64 + <template #value>{{ vulnerability.base_score }}</template>
65 + </Badge>
66 + </div>
67 + <div v-if="vulnerability.epss_score" class="detail-item">
68 + <label>EPSS Score</label>
69 + <Badge color="warning" type="splitted">
70 + <template #label>Score</template>
71 + <template #value>{{ parseFloat(vulnerability.epss_score).toFixed(3) }}</template>
72 + </Badge>
73 + </div>
74 + <div v-if="vulnerability.epss_percentile" class="detail-item">
75 + <label>EPSS Percentile</label>
76 + <Badge color="warning" type="splitted">
77 + <template #label>Percentile</template>
78 + <template #value>{{ parseFloat(vulnerability.epss_percentile).toFixed(1) }}%</template>
79 + </Badge>
80 + </div>
81 + </div>
82 + </div>
83 +
84 + <!-- Timeline -->
85 + <div class="section">
86 + <h3 class="section-title">Timeline</h3>
87 + <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
88 + <div class="detail-item">
89 + <label>Detected At</label>
90 + <div class="value">{{ formatDateTime(vulnerability.detected_at) }}</div>
91 + </div>
92 + <div v-if="vulnerability.published_at" class="detail-item">
93 + <label>Published At</label>
94 + <div class="value">{{ formatDateTime(vulnerability.published_at) }}</div>
95 + </div>
96 + </div>
97 + </div>
98 +
99 + <!-- References -->
100 + <div class="section">
101 + <h3 class="section-title">References</h3>
102 + <div class="value">
103 + <div v-if="vulnerability.references && parseReferences(vulnerability.references).length > 0">
104 + <div v-for="(ref, index) in parseReferences(vulnerability.references)" :key="index" class="mb-2">
105 + <a :href="ref" target="_blank" rel="noopener noreferrer" class="reference-link">
106 + {{ ref }}
107 + </a>
108 + </div>
109 + </div>
110 + <div v-else class="text-gray-500 dark:text-gray-400 italic">
111 + No references available
112 + </div>
113 + </div>
114 + </div>
115 + </div>
116 + </n-scrollbar>
117 + </div>
118 +</template>
119 +
120 +<script setup lang="ts">
121 +import type { VulnerabilitySearchItem } from "@/types/vulnerabilities.d"
122 +import { NScrollbar } from "naive-ui"
123 +import Badge from "@/components/common/Badge.vue"
124 +import Icon from "@/components/common/Icon.vue"
125 +import { VulnerabilitySeverity } from "@/types/vulnerabilities.d"
126 +
127 +const { vulnerability } = defineProps<{ vulnerability: VulnerabilitySearchItem }>()
128 +
129 +function getSeverityIcon(severity: string): string {
130 + const iconMap: Record<string, string> = {
131 + [VulnerabilitySeverity.Critical]: "carbon:warning-filled",
132 + [VulnerabilitySeverity.High]: "carbon:warning",
133 + [VulnerabilitySeverity.Medium]: "carbon:warning-alt",
134 + [VulnerabilitySeverity.Low]: "carbon:information"
135 + }
136 + return iconMap[severity] || "carbon:help"
137 +}
138 +
139 +function getSeverityColor(severity: string): "primary" | "warning" | "success" | "danger" | undefined {
140 + const colorMap: Record<string, "primary" | "warning" | "success" | "danger"> = {
141 + [VulnerabilitySeverity.Critical]: "danger",
142 + [VulnerabilitySeverity.High]: "warning",
143 + [VulnerabilitySeverity.Medium]: "warning",
144 + [VulnerabilitySeverity.Low]: "primary"
145 + }
146 + return colorMap[severity]
147 +}
148 +
149 +function formatDateTime(dateString: string): string {
150 + return new Date(dateString).toLocaleString()
151 +}
152 +
153 +function parseReferences(references: string): string[] {
154 + if (!references || references.trim() === '') return []
155 +
156 + // Try to handle different formats:
157 + // 1. JSON array string
158 + try {
159 + const parsed = JSON.parse(references)
160 + if (Array.isArray(parsed)) {
161 + return parsed.filter(ref => ref && typeof ref === 'string' && ref.trim().length > 0)
162 + }
163 + } catch {
164 + // Not JSON, continue with other parsing methods
165 + }
166 +
167 + // 2. Comma, semicolon, or newline separated
168 + let refs = references.split(/[,;\n|]/).map(ref => ref.trim()).filter(ref => ref.length > 0)
169 +
170 + // 3. Space separated URLs (if they start with http)
171 + if (refs.length === 1 && refs[0].includes('http')) {
172 + const spaceRefs = refs[0].split(/\s+/).filter(ref => ref.startsWith('http'))
173 + if (spaceRefs.length > 1) {
174 + refs = spaceRefs
175 + }
176 + }
177 +
178 + return refs
179 +}
180 +</script>
181 +
182 +<style scoped>
183 +.vulnerability-details {
184 + max-height: 75vh;
185 + overflow-y: auto;
186 +}
187 +
188 +.section {
189 + border-bottom: 1px solid rgb(229 231 235);
190 + padding-bottom: 1rem;
191 + margin-bottom: 1rem;
192 +}
193 +
194 +.section:last-child {
195 + border-bottom: none;
196 + margin-bottom: 0;
197 +}
198 +
199 +.section-title {
200 + font-size: 1.125rem;
201 + font-weight: 600;
202 + margin-bottom: 0.75rem;
203 + color: rgb(17 24 39);
204 +}
205 +
206 +.detail-item {
207 + display: flex;
208 + flex-direction: column;
209 + gap: 0.25rem;
210 +}
211 +
212 +.detail-item label {
213 + font-size: 0.875rem;
214 + font-weight: 500;
215 + color: rgb(75 85 99);
216 +}
217 +
218 +.detail-item .value {
219 + font-size: 0.875rem;
220 + color: rgb(17 24 39);
221 +}
222 +
223 +.reference-link {
224 + color: rgb(37 99 235);
225 + text-decoration: underline;
226 + word-break: break-all;
227 +}
228 +
229 +.reference-link:hover {
230 + color: rgb(29 78 216);
231 +}
232 +
233 +/* Dark mode styles */
234 +:deep(.dark) .section,
235 +.dark .section {
236 + border-bottom-color: rgb(55 65 81);
237 +}
238 +
239 +:deep(.dark) .section-title,
240 +.dark .section-title {
241 + color: rgb(243 244 246);
242 +}
243 +
244 +:deep(.dark) .detail-item label,
245 +.dark .detail-item label {
246 + color: rgb(156 163 175);
247 +}
248 +
249 +:deep(.dark) .detail-item .value,
250 +.dark .detail-item .value {
251 + color: rgb(243 244 246);
252 +}
253 +
254 +:deep(.dark) .reference-link,
255 +.dark .reference-link {
256 + color: rgb(96 165 250);
257 +}
258 +
259 +:deep(.dark) .reference-link:hover,
260 +.dark .reference-link:hover {
261 + color: rgb(147 197 253);
262 +}
263 +
264 +/* For better compatibility with different dark mode implementations */
265 +@media (prefers-color-scheme: dark) {
266 + .section {
267 + border-bottom-color: rgb(55 65 81);
268 + }
269 +
270 + .section-title {
271 + color: rgb(243 244 246);
272 + }
273 +
274 + .detail-item label {
275 + color: rgb(156 163 175);
276 + }
277 +
278 + .detail-item .value {
279 + color: rgb(243 244 246);
280 + }
281 +
282 + .reference-link {
283 + color: rgb(96 165 250);
284 + }
285 +
286 + .reference-link:hover {
287 + color: rgb(147 197 253);
288 + }
289 +}
290 +</style>
frontend/src/router/index.ts
+6
@@ -67,6 +67,12 @@ const router = createRouter({
67 name: "CopilotActions",
68 component: () => import("@/views/agents/CopilotActions.vue"),
69 meta: { title: "CoPilot Actions" }
70 + },
71 + {
72 + path: "vulnerability-overview",
73 + name: "VulnerabilityOverview",
74 + component: () => import("@/views/agents/VulnerabilityOverview.vue"),
75 + meta: { title: "Vulnerability Overview" }
76 }
77 ]
78 },
frontend/src/types/vulnerabilities.d.ts new
+51
@@ -0,0 +1,51 @@
1 +export interface VulnerabilitySearchItem {
2 + cve_id: string
3 + severity: VulnerabilitySeverity
4 + title: string
5 + agent_name: string
6 + customer_code?: string | null
7 + references?: string | null
8 + detected_at: string
9 + published_at?: string | null
10 + base_score?: number | null
11 + package_name?: string | null
12 + package_version?: string | null
13 + package_architecture?: string | null
14 + epss_score?: string | null
15 + epss_percentile?: string | null
16 +}
17 +
18 +export interface VulnerabilitySearchResponse {
19 + vulnerabilities: VulnerabilitySearchItem[]
20 + total_count: number
21 + critical_count: number
22 + high_count: number
23 + medium_count: number
24 + low_count: number
25 + page: number
26 + page_size: number
27 + total_pages: number
28 + has_next: boolean
29 + has_previous: boolean
30 + success: boolean
31 + message: string
32 + filters_applied: Record<string, any>
33 +}
34 +
35 +export enum VulnerabilitySeverity {
36 + Critical = "Critical",
37 + High = "High",
38 + Medium = "Medium",
39 + Low = "Low"
40 +}
41 +
42 +export interface VulnerabilitySearchQuery {
43 + customer_code?: string
44 + agent_name?: string
45 + severity?: VulnerabilitySeverity
46 + cve_id?: string
47 + package_name?: string
48 + page?: number
49 + page_size?: number
50 + include_epss?: boolean
51 +}
frontend/src/views/agents/VulnerabilityOverview.vue new
+9
@@ -0,0 +1,9 @@
1 +<template>
2 + <div class="page">
3 + <List />
4 + </div>
5 +</template>
6 +
7 +<script setup lang="ts">
8 +import List from "@/components/vulnerabilities/List.vue"
9 +</script>