From 8ee0c0c476e1652d59ef8e4f53dd728996e36250 Mon Sep 17 00:00:00 2001 From: Anso Date: Thu, 16 Apr 2026 23:15:36 -0400 Subject: [PATCH] feat(security): scan comparison UI (#648) Side-by-side vulnerability scan comparison with two entry points: - Compare button plus inline baseline picker inside the scan drawer. - New Scan History page reachable from the Resources Hub, grouping completed scans by image with a checkbox selection flow. The comparison sheet shows a severity delta ribbon, Added/Removed/Unchanged filters, and a paginated CVE table. Cross-image comparisons are allowed but flagged with a warning. Compare access is gated to Skipper and Admiral tiers; the underlying /security/compare endpoint is unchanged. --- docs/features/vulnerability-scanning.mdx | 22 ++ frontend/src/components/EditorLayout.tsx | 5 +- frontend/src/components/NodeManager.tsx | 4 +- frontend/src/components/ResourcesView.tsx | 20 +- .../src/components/ScanComparisonSheet.tsx | 348 ++++++++++++++++++ .../src/components/SecurityHistoryView.tsx | 310 ++++++++++++++++ .../src/components/VulnerabilityScanSheet.tsx | 86 ++++- frontend/src/types/security.ts | 17 + 8 files changed, 806 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/ScanComparisonSheet.tsx create mode 100644 frontend/src/components/SecurityHistoryView.tsx diff --git a/docs/features/vulnerability-scanning.mdx b/docs/features/vulnerability-scanning.mdx index 3c595b6f..fd726482 100644 --- a/docs/features/vulnerability-scanning.mdx +++ b/docs/features/vulnerability-scanning.mdx @@ -170,6 +170,24 @@ Every scan Sencho runs is stored with its full vulnerability detail. Scan record - **Digest caching**: skip re-scanning an image that has already been scanned within 24 hours. - **Trend badges**: surface whether the latest scan added or resolved vulnerabilities compared to the previous scan for the same image. +Open the **Scan history** page from the top of the Resources Hub to browse completed scans grouped by image, search by image reference, and pick two scans to compare. + +## Comparing scans Skipper + +Compare any two completed scans for an image to see what changed between them. + +**From the Scan history page**: select two scans via the checkboxes (one baseline, one newer) and click **Compare**. Selecting a third scan replaces the oldest selection. + +**From an open scan**: click **Compare** in the drawer header, then pick a baseline scan from the dropdown. Only completed scans for the same image appear. + +The comparison sheet shows: + +- A **delta ribbon** summarizing the net change per severity (CRITICAL, HIGH, MEDIUM, LOW). +- Filter pills to switch between **Added** (new findings since the baseline), **Removed** (resolved findings), and **Unchanged** (findings present in both). +- A sorted table of CVEs with severity, affected package, and direct links to Trivy's primary URL when available. + +Cross-image comparisons (picking scans from two different image references) are allowed but flagged with a warning, since package-level changes may reflect image differences rather than CVE drift. + ## How it works 1. On startup, Sencho looks for the `trivy` binary on `PATH` and caches its availability. @@ -211,3 +229,7 @@ Enable **Developer Mode** under **Settings → Developer** and trigger the faili ### Post-deploy scan failure notifications When a post-deploy scan fails for a specific image (for example because Trivy could not resolve a private registry pull), Sencho dispatches a warning-level alert through your configured notification channels. The deploy itself is never blocked by a scan failure. + +### Compare button is disabled + +Two completed scans are required to run a comparison. If you have only one scan for an image, trigger a second scan from the Resources Hub (or wait for a scheduled scan), then return to the Scan history page and tick both. diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 9363201f..7f711a61 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -54,6 +54,7 @@ import { FleetView } from './FleetView'; import { AuditLogView } from './AuditLogView'; import ScheduledOperationsView from './ScheduledOperationsView'; import AutoUpdatePoliciesView from './AutoUpdatePoliciesView'; +import { SecurityHistoryView } from './SecurityHistoryView'; import { SENCHO_NAVIGATE_EVENT } from './NodeManager'; import type { SenchoNavigateDetail } from './NodeManager'; import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events'; @@ -201,7 +202,7 @@ export default function EditorLayout() { window.matchMedia('(prefers-color-scheme: dark)').matches ); const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark); - const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log' | 'scheduled-ops' | 'auto-updates'>('dashboard'); + const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log' | 'scheduled-ops' | 'auto-updates' | 'security-history'>('dashboard'); const [filterNodeId, setFilterNodeId] = useState(null); const [isEditing, setIsEditing] = useState(false); const [searchQuery, setSearchQuery] = useState(''); @@ -2728,6 +2729,8 @@ export default function EditorLayout() { setFilterNodeId(null)} /> + ) : activeView === 'security-history' ? ( + ) : ( { loadFile(stackFile); }} diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index 426fc753..8596b0ce 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -24,8 +24,8 @@ interface NodeSchedulingSummary { export const SENCHO_NAVIGATE_EVENT = 'sencho-navigate'; export interface SenchoNavigateDetail { - view: 'scheduled-ops' | 'auto-updates'; - nodeId: number; + view: 'scheduled-ops' | 'auto-updates' | 'security-history'; + nodeId?: number; } function formatRelativeTime(timestamp: number): string { diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index 6d48d4a8..a1f1abe1 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -17,10 +17,11 @@ import { Switch } from "@/components/ui/switch"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; -import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Plus, Eye, Copy, Container, Loader2 } from 'lucide-react'; +import { Trash2, HardDrive, Network, PackageMinus, MonitorX, MoreVertical, AlertTriangle, ShieldCheck, Plus, Eye, Copy, Container, Loader2, History } from 'lucide-react'; import { CursorProvider, CursorContainer, Cursor, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; import { useTrivyStatus } from '@/hooks/useTrivyStatus'; import { VulnerabilityScanSheet } from './VulnerabilityScanSheet'; +import { SENCHO_NAVIGATE_EVENT, type SenchoNavigateDetail } from './NodeManager'; import type { ScanSummary, VulnSeverity } from '@/types/security'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; @@ -705,6 +706,22 @@ export default function ResourcesView() { {activeNode?.type === 'remote' && ( - {activeNode.name} )} + {trivy.available && isPaid && ( + + )} {/* Top row: Footprint + Quick Clean */} @@ -1473,6 +1490,7 @@ export default function ResourcesView() { onClose={() => setInspectScanId(null)} onRescan={(imageRef) => { setInspectScanId(null); handleScanImage(imageRef, true); }} canGenerateSbom={isPaid} + canCompare={isPaid} /> ); diff --git a/frontend/src/components/ScanComparisonSheet.tsx b/frontend/src/components/ScanComparisonSheet.tsx new file mode 100644 index 00000000..4581dea1 --- /dev/null +++ b/frontend/src/components/ScanComparisonSheet.tsx @@ -0,0 +1,348 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Button } from '@/components/ui/button'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + ArrowRight, + ChevronLeft, + ChevronRight, + GitCompare, + Loader2, + MinusCircle, + PlusCircle, + ShieldCheck, + Equal, + AlertTriangle, +} from 'lucide-react'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { cn } from '@/lib/utils'; +import { SeverityChip } from './VulnerabilityScanSheet'; +import type { + ScanCompareResult, + ScanCompareVulnerability, + VulnSeverity, +} from '@/types/security'; + +interface ScanComparisonSheetProps { + baselineScanId: number | null; + currentScanId: number | null; + onClose: () => void; +} + +type DiffFilter = 'added' | 'removed' | 'unchanged'; + +const PAGE_SIZE = 25; + +const SEVERITY_ORDER: Record = { + CRITICAL: 0, + HIGH: 1, + MEDIUM: 2, + LOW: 3, + UNKNOWN: 4, +}; + +function sortBySeverity(rows: T[]): T[] { + return [...rows].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]); +} + +function countBySeverity(rows: Array<{ severity: VulnSeverity }>): Record { + const counts: Record = { + CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, UNKNOWN: 0, + }; + for (const r of rows) counts[r.severity] += 1; + return counts; +} + +function formatDelta(added: number, removed: number): { text: string; tone: 'success' | 'warning' | 'muted' } { + const net = added - removed; + if (net > 0) return { text: `+${net}`, tone: 'warning' }; + if (net < 0) return { text: `${net}`, tone: 'success' }; + return { text: '0', tone: 'muted' }; +} + +export function ScanComparisonSheet({ + baselineScanId, + currentScanId, + onClose, +}: ScanComparisonSheetProps) { + const [loading, setLoading] = useState(false); + const [data, setData] = useState(null); + const [filter, setFilter] = useState('added'); + const [page, setPage] = useState(0); + + const load = useCallback(async () => { + if (baselineScanId == null || currentScanId == null) return; + setLoading(true); + setData(null); + setPage(0); + setFilter('added'); + try { + const res = await apiFetch( + `/security/compare?scanId1=${baselineScanId}&scanId2=${currentScanId}`, + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.error || 'Failed to load comparison'); + } + const body = (await res.json()) as ScanCompareResult; + setData(body); + } catch (err) { + toast.error((err as Error)?.message || 'Failed to load comparison'); + onClose(); + } finally { + setLoading(false); + } + }, [baselineScanId, currentScanId, onClose]); + + useEffect(() => { + if (baselineScanId != null && currentScanId != null) load(); + }, [baselineScanId, currentScanId, load]); + + const open = baselineScanId != null && currentScanId != null; + + const addedCounts = useMemo(() => (data ? countBySeverity(data.added) : null), [data]); + const removedCounts = useMemo(() => (data ? countBySeverity(data.removed) : null), [data]); + + const crossImage = data != null && data.scanA.image_ref !== data.scanB.image_ref; + + const rows = useMemo(() => { + if (!data) return []; + if (filter === 'added') return sortBySeverity(data.added); + if (filter === 'removed') return sortBySeverity(data.removed); + return sortBySeverity(data.unchanged as ScanCompareVulnerability[]); + }, [data, filter]); + + const totalPages = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); + const safePage = Math.min(page, totalPages - 1); + const pageItems = rows.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE); + const needsPagination = rows.length > PAGE_SIZE; + + return ( + !o && onClose()}> + + + + + + Compare scans + + + + Side-by-side comparison of two vulnerability scans showing added, removed, and unchanged findings. + + + + {loading && ( +
+ +
+ )} + + {data && !loading && ( +
+ {/* Scan identification */} +
+
+
+
Baseline
+
{data.scanA.image_ref}
+
{new Date(data.scanA.scanned_at).toLocaleString()}
+
+ +
+
Current
+
{data.scanB.image_ref}
+
{new Date(data.scanB.scanned_at).toLocaleString()}
+
+
+ + {crossImage && ( +
+ + + You are comparing scans from two different image references. Package-level changes may reflect image differences rather than CVE drift. + +
+ )} + + {/* Delta ribbon */} + {addedCounts && removedCounts && ( +
+ {(['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as VulnSeverity[]).map((sev) => { + const delta = formatDelta(addedCounts[sev], removedCounts[sev]); + const toneClass = + delta.tone === 'warning' + ? 'text-warning border-warning/40 bg-warning/10' + : delta.tone === 'success' + ? 'text-success border-success/40 bg-success/10' + : 'text-muted-foreground border-border bg-muted/30'; + return ( + + {sev} + {delta.text} + + ); + })} +
+ )} +
+ + {/* Filter pills */} +
+ + + + {needsPagination && ( +
+ + + {safePage + 1} / {totalPages} + + +
+ )} +
+ + +
+ {pageItems.length === 0 ? ( +
+ +
+ {filter === 'added' && 'No new findings. Nothing regressed between these scans.'} + {filter === 'removed' && 'No findings were resolved between these scans.'} + {filter === 'unchanged' && 'No findings are shared between the two scans.'} +
+
+ ) : ( + + + + CVE + Package + Severity + Status + + + + {pageItems.map((v, idx) => { + const rowClass = + filter === 'added' + ? 'bg-destructive/5' + : filter === 'removed' + ? 'bg-success/5' + : 'opacity-70'; + return ( + + + {v.primary_url ? ( + + {v.vulnerability_id} + + ) : ( + v.vulnerability_id + )} + + + {v.pkg_name} + + + + + + {filter === 'added' && ( + + + Added + + )} + {filter === 'removed' && ( + + + Removed + + )} + {filter === 'unchanged' && ( + + + Unchanged + + )} + + + ); + })} + +
+ )} +
+
+
+ )} +
+
+ ); +} + +export type { ScanComparisonSheetProps }; diff --git a/frontend/src/components/SecurityHistoryView.tsx b/frontend/src/components/SecurityHistoryView.tsx new file mode 100644 index 00000000..f93cd2c0 --- /dev/null +++ b/frontend/src/components/SecurityHistoryView.tsx @@ -0,0 +1,310 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + ChevronLeft, + ChevronRight, + GitCompare, + History, + RefreshCw, + Search, + ShieldCheck, +} from 'lucide-react'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import { cn } from '@/lib/utils'; +import { ScanComparisonSheet } from './ScanComparisonSheet'; +import { SeverityChip } from './VulnerabilityScanSheet'; +import { VulnerabilityScanSheet } from './VulnerabilityScanSheet'; +import { useLicense } from '@/context/LicenseContext'; +import { useNodes } from '@/context/NodeContext'; +import type { VulnerabilityScan } from '@/types/security'; + +const PAGE_SIZE = 25; + +interface GroupedScans { + image_ref: string; + scans: VulnerabilityScan[]; +} + +function groupByImage(scans: VulnerabilityScan[]): GroupedScans[] { + const map = new Map(); + for (const s of scans) { + const list = map.get(s.image_ref) ?? []; + list.push(s); + map.set(s.image_ref, list); + } + const groups: GroupedScans[] = []; + for (const [image_ref, list] of map.entries()) { + list.sort((a, b) => b.scanned_at - a.scanned_at); + groups.push({ image_ref, scans: list }); + } + groups.sort((a, b) => (b.scans[0]?.scanned_at ?? 0) - (a.scans[0]?.scanned_at ?? 0)); + return groups; +} + +export function SecurityHistoryView() { + const { isPaid } = useLicense(); + const { activeNode } = useNodes(); + const [scans, setScans] = useState([]); + const [loading, setLoading] = useState(false); + const [search, setSearch] = useState(''); + const [selected, setSelected] = useState([]); + const [compareIds, setCompareIds] = useState<[number, number] | null>(null); + const [inspectScanId, setInspectScanId] = useState(null); + const [page, setPage] = useState(0); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await apiFetch('/security/scans?limit=200'); + if (!res.ok) throw new Error('Failed to load scans'); + const body = await res.json(); + const items: VulnerabilityScan[] = Array.isArray(body?.items) ? body.items : []; + setScans(items.filter((s) => s.status === 'completed')); + } catch (err) { + toast.error((err as Error)?.message || 'Could not load scan history'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(); + setSelected([]); + }, [load, activeNode?.id]); + + const filteredScans = useMemo(() => { + if (!search.trim()) return scans; + const q = search.toLowerCase(); + return scans.filter((s) => s.image_ref.toLowerCase().includes(q)); + }, [scans, search]); + + const groups = useMemo(() => groupByImage(filteredScans), [filteredScans]); + + const totalPages = Math.max(1, Math.ceil(groups.length / PAGE_SIZE)); + const safePage = Math.min(page, totalPages - 1); + const pageGroups = groups.slice(safePage * PAGE_SIZE, (safePage + 1) * PAGE_SIZE); + const needsPagination = groups.length > PAGE_SIZE; + + const toggleSelect = (scanId: number) => { + setSelected((prev) => { + if (prev.includes(scanId)) return prev.filter((x) => x !== scanId); + if (prev.length >= 2) return [prev[1], scanId]; + return [...prev, scanId]; + }); + }; + + const compareSelected = () => { + if (selected.length !== 2) return; + const [aId, bId] = selected; + const a = scans.find((s) => s.id === aId); + const b = scans.find((s) => s.id === bId); + if (!a || !b) return; + const [older, newer] = a.scanned_at <= b.scanned_at ? [a, b] : [b, a]; + setCompareIds([older.id, newer.id]); + }; + + const compareDisabled = selected.length !== 2; + + return ( +
+ + +
+
+ + Scan History +
+
+ + +
+
+

+ Completed vulnerability scans on this node, grouped by image. Select two to compare. +

+
+ +
+
+ + { setSearch(e.target.value); setPage(0); }} + className="pl-8" + /> +
+ {needsPagination && ( +
+ + + {safePage + 1} / {totalPages} + + +
+ )} +
+ + {groups.length === 0 && !loading ? ( +
+ +
+ {search + ? 'No completed scans match your search.' + : 'No scans have completed on this node yet.'} +
+
+ ) : ( + +
+ {pageGroups.map((group) => ( +
+
+ + {group.image_ref} + + + {group.scans.length} scan{group.scans.length === 1 ? '' : 's'} + +
+ + + + + Scanned + Trigger + Highest + Total + Fixable + + + + + {group.scans.map((scan) => { + const isSelected = selected.includes(scan.id); + return ( + + + toggleSelect(scan.id)} + aria-label={`Select scan ${scan.id}`} + /> + + + {new Date(scan.scanned_at).toLocaleString()} + + + {scan.triggered_by} + + + {scan.highest_severity ? ( + + ) : ( + none + )} + + + {scan.total_vulnerabilities} + + + {scan.fixable_count} + + + + + + ); + })} + +
+
+ ))} +
+
+ )} +
+
+ + setCompareIds(null)} + /> + + setInspectScanId(null)} + canGenerateSbom={isPaid} + canCompare={false} + /> +
+ ); +} diff --git a/frontend/src/components/VulnerabilityScanSheet.tsx b/frontend/src/components/VulnerabilityScanSheet.tsx index 91e14808..8abe2b93 100644 --- a/frontend/src/components/VulnerabilityScanSheet.tsx +++ b/frontend/src/components/VulnerabilityScanSheet.tsx @@ -25,7 +25,10 @@ import { Download, Loader2, Check, + GitCompare, } from 'lucide-react'; +import { Combobox } from '@/components/ui/combobox'; +import { ScanComparisonSheet } from './ScanComparisonSheet'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { cn } from '@/lib/utils'; @@ -40,6 +43,7 @@ interface VulnerabilityScanSheetProps { onClose: () => void; onRescan?: (imageRef: string) => void; canGenerateSbom?: boolean; + canCompare?: boolean; } type SeverityFilter = 'ALL' | VulnSeverity; @@ -72,6 +76,7 @@ export function VulnerabilityScanSheet({ onClose, onRescan, canGenerateSbom = false, + canCompare = false, }: VulnerabilityScanSheetProps) { const [scan, setScan] = useState(null); const [details, setDetails] = useState([]); @@ -80,6 +85,10 @@ export function VulnerabilityScanSheet({ const [severityFilter, setSeverityFilter] = useState('ALL'); const [page, setPage] = useState(0); const [downloadingSbom, setDownloadingSbom] = useState(false); + const [compareOpen, setCompareOpen] = useState(false); + const [compareOptions, setCompareOptions] = useState([]); + const [compareLoading, setCompareLoading] = useState(false); + const [compareBaselineId, setCompareBaselineId] = useState(null); const DETAIL_FETCH_LIMIT = 500; @@ -107,8 +116,12 @@ export function VulnerabilityScanSheet({ }, [scanId]); useEffect(() => { - if (scanId != null) load(); - else { + setCompareOpen(false); + setCompareOptions([]); + setCompareBaselineId(null); + if (scanId != null) { + load(); + } else { setScan(null); setDetails([]); setTotalDetails(0); @@ -159,6 +172,28 @@ export function VulnerabilityScanSheet({ [scan], ); + const openCompareMenu = useCallback(async () => { + if (!scan) return; + setCompareOpen((o) => !o); + if (compareOptions.length > 0 || compareLoading) return; + setCompareLoading(true); + try { + const res = await apiFetch( + `/security/scans?imageRef=${encodeURIComponent(scan.image_ref)}&limit=25`, + ); + if (!res.ok) throw new Error('Failed to load scan history'); + const body = await res.json(); + const items: VulnerabilityScan[] = Array.isArray(body?.items) ? body.items : []; + setCompareOptions( + items.filter((s) => s.id !== scan.id && s.status === 'completed'), + ); + } catch (err) { + toast.error((err as Error)?.message || 'Could not load scan history'); + } finally { + setCompareLoading(false); + } + }, [scan, compareOptions.length, compareLoading]); + const exportCsv = useCallback(() => { if (!scan || details.length === 0) return; const header = 'CVE,Package,Severity,Installed,Fixed,URL\n'; @@ -300,7 +335,49 @@ export function VulnerabilityScanSheet({ CSV + {canCompare && ( + + )} + + {compareOpen && canCompare && ( +
+ {compareOptions.length === 0 && !compareLoading ? ( +
+ No other completed scans for this image yet. Run a second scan to enable comparison. +
+ ) : ( + <> +
+ Compare against +
+ ({ + value: String(s.id), + label: `${new Date(s.scanned_at).toLocaleString()} - ${s.total_vulnerabilities} findings (${s.triggered_by})`, + }))} + value={compareBaselineId != null ? String(compareBaselineId) : ''} + onValueChange={(v) => setCompareBaselineId(v ? Number(v) : null)} + placeholder="Choose a baseline scan..." + searchPlaceholder="Search by date..." + /> + + )} +
+ )} {/* Severity filter tabs */} @@ -416,6 +493,11 @@ export function VulnerabilityScanSheet({ )} + setCompareBaselineId(null)} + /> ); } diff --git a/frontend/src/types/security.ts b/frontend/src/types/security.ts index 5a4cd8d5..38234004 100644 --- a/frontend/src/types/security.ts +++ b/frontend/src/types/security.ts @@ -80,3 +80,20 @@ export interface ScanPolicy { created_at: number; updated_at: number; } + +export interface ScanCompareVulnerability { + vulnerability_id: string; + pkg_name: string; + severity: VulnSeverity; + installed_version?: string; + fixed_version?: string | null; + primary_url?: string | null; +} + +export interface ScanCompareResult { + scanA: { id: number; scanned_at: number; image_ref: string }; + scanB: { id: number; scanned_at: number; image_ref: string }; + added: ScanCompareVulnerability[]; + removed: ScanCompareVulnerability[]; + unchanged: Pick[]; +}