mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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.
This commit is contained in:
@@ -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 <Badge>Skipper</Badge>
|
||||
|
||||
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.
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -2728,6 +2729,8 @@ export default function EditorLayout() {
|
||||
<CapabilityGate capability="scheduled-ops" featureName="Scheduled Operations">
|
||||
<ScheduledOperationsView filterNodeId={filterNodeId} onClearFilter={() => setFilterNodeId(null)} />
|
||||
</CapabilityGate>
|
||||
) : activeView === 'security-history' ? (
|
||||
<SecurityHistoryView />
|
||||
) : (
|
||||
<HomeDashboard
|
||||
onNavigateToStack={(stackFile) => { loadFile(stackFile); }}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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' && (
|
||||
<span className="text-sm text-muted-foreground">- {activeNode.name}</span>
|
||||
)}
|
||||
{trivy.available && isPaid && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto border-border"
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent<SenchoNavigateDetail>(SENCHO_NAVIGATE_EVENT, {
|
||||
detail: { view: 'security-history' },
|
||||
}));
|
||||
}}
|
||||
title="View completed vulnerability scans and compare them"
|
||||
>
|
||||
<History className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
Scan history
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<VulnSeverity, number> = {
|
||||
CRITICAL: 0,
|
||||
HIGH: 1,
|
||||
MEDIUM: 2,
|
||||
LOW: 3,
|
||||
UNKNOWN: 4,
|
||||
};
|
||||
|
||||
function sortBySeverity<T extends { severity: VulnSeverity }>(rows: T[]): T[] {
|
||||
return [...rows].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
||||
}
|
||||
|
||||
function countBySeverity(rows: Array<{ severity: VulnSeverity }>): Record<VulnSeverity, number> {
|
||||
const counts: Record<VulnSeverity, number> = {
|
||||
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<ScanCompareResult | null>(null);
|
||||
const [filter, setFilter] = useState<DiffFilter>('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<ScanCompareVulnerability[]>(() => {
|
||||
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 (
|
||||
<Sheet open={open} onOpenChange={(o) => !o && onClose()}>
|
||||
<SheetContent className="sm:max-w-4xl flex flex-col p-0">
|
||||
<SheetHeader className="p-6 pb-4 border-b">
|
||||
<SheetTitle className="flex items-center gap-2 pr-6">
|
||||
<GitCompare className="w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
|
||||
<span className="font-mono text-sm truncate">
|
||||
Compare scans
|
||||
</span>
|
||||
</SheetTitle>
|
||||
<SheetDescription className="sr-only">
|
||||
Side-by-side comparison of two vulnerability scans showing added, removed, and unchanged findings.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && !loading && (
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
{/* Scan identification */}
|
||||
<div className="px-6 py-4 border-b space-y-3">
|
||||
<div className="flex items-center gap-3 text-xs font-mono tabular-nums">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-stat-subtitle uppercase tracking-wide text-[10px]">Baseline</div>
|
||||
<div className="text-stat-value truncate">{data.scanA.image_ref}</div>
|
||||
<div className="text-stat-subtitle">{new Date(data.scanA.scanned_at).toLocaleString()}</div>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-stat-subtitle uppercase tracking-wide text-[10px]">Current</div>
|
||||
<div className="text-stat-value truncate">{data.scanB.image_ref}</div>
|
||||
<div className="text-stat-subtitle">{new Date(data.scanB.scanned_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{crossImage && (
|
||||
<div className="flex items-start gap-2 rounded border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-[1px]" strokeWidth={1.5} />
|
||||
<span>
|
||||
You are comparing scans from two different image references. Package-level changes may reflect image differences rather than CVE drift.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delta ribbon */}
|
||||
{addedCounts && removedCounts && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(['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 (
|
||||
<span
|
||||
key={sev}
|
||||
aria-label={`${sev} delta ${delta.text}`}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded border px-2 py-1 text-xs font-mono tabular-nums',
|
||||
toneClass,
|
||||
)}
|
||||
>
|
||||
<span className="uppercase tracking-wide text-[10px]">{sev}</span>
|
||||
<span>{delta.text}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter pills */}
|
||||
<div className="px-6 pt-3 flex items-center gap-1 flex-wrap">
|
||||
<Button
|
||||
variant={filter === 'added' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => { setFilter('added'); setPage(0); }}
|
||||
>
|
||||
<PlusCircle className="w-3 h-3 mr-1" strokeWidth={1.5} />
|
||||
Added ({data.added.length})
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'removed' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => { setFilter('removed'); setPage(0); }}
|
||||
>
|
||||
<MinusCircle className="w-3 h-3 mr-1" strokeWidth={1.5} />
|
||||
Removed ({data.removed.length})
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'unchanged' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
className="h-7 text-xs px-2.5"
|
||||
onClick={() => { setFilter('unchanged'); setPage(0); }}
|
||||
>
|
||||
<Equal className="w-3 h-3 mr-1" strokeWidth={1.5} />
|
||||
Unchanged ({data.unchanged.length})
|
||||
</Button>
|
||||
{needsPagination && (
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.max(0, safePage - 1))}
|
||||
disabled={safePage === 0}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
|
||||
{safePage + 1} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="px-6 py-3">
|
||||
{pageItems.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center text-center py-16 gap-2">
|
||||
<ShieldCheck className="w-8 h-8 text-success" strokeWidth={1.5} />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{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.'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[180px]">CVE</TableHead>
|
||||
<TableHead>Package</TableHead>
|
||||
<TableHead className="w-[100px]">Severity</TableHead>
|
||||
<TableHead className="w-[110px]">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pageItems.map((v, idx) => {
|
||||
const rowClass =
|
||||
filter === 'added'
|
||||
? 'bg-destructive/5'
|
||||
: filter === 'removed'
|
||||
? 'bg-success/5'
|
||||
: 'opacity-70';
|
||||
return (
|
||||
<TableRow key={`${v.vulnerability_id}-${v.pkg_name}-${idx}`} className={rowClass}>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{v.primary_url ? (
|
||||
<a
|
||||
href={v.primary_url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="hover:underline"
|
||||
>
|
||||
{v.vulnerability_id}
|
||||
</a>
|
||||
) : (
|
||||
v.vulnerability_id
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs truncate max-w-[180px]" title={v.pkg_name}>
|
||||
{v.pkg_name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<SeverityChip severity={v.severity} />
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{filter === 'added' && (
|
||||
<span className="inline-flex items-center gap-1 text-destructive">
|
||||
<PlusCircle className="w-3 h-3" strokeWidth={1.5} />
|
||||
Added
|
||||
</span>
|
||||
)}
|
||||
{filter === 'removed' && (
|
||||
<span className="inline-flex items-center gap-1 text-success">
|
||||
<MinusCircle className="w-3 h-3" strokeWidth={1.5} />
|
||||
Removed
|
||||
</span>
|
||||
)}
|
||||
{filter === 'unchanged' && (
|
||||
<span className="inline-flex items-center gap-1 text-muted-foreground">
|
||||
<Equal className="w-3 h-3" strokeWidth={1.5} />
|
||||
Unchanged
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export type { ScanComparisonSheetProps };
|
||||
@@ -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<string, VulnerabilityScan[]>();
|
||||
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<VulnerabilityScan[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<number[]>([]);
|
||||
const [compareIds, setCompareIds] = useState<[number, number] | null>(null);
|
||||
const [inspectScanId, setInspectScanId] = useState<number | null>(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 (
|
||||
<div className="flex-1 flex flex-col gap-4 overflow-auto">
|
||||
<Card className="rounded-lg border border-card-border border-t-card-border-top bg-card text-card-foreground shadow-card-bevel">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<History className="w-5 h-5" strokeWidth={1.5} />
|
||||
<CardTitle>Scan History</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-border"
|
||||
onClick={compareSelected}
|
||||
disabled={compareDisabled || !isPaid}
|
||||
title={
|
||||
!isPaid
|
||||
? 'Scan comparison requires Skipper or Admiral'
|
||||
: compareDisabled
|
||||
? 'Select exactly two completed scans to compare'
|
||||
: 'Compare the two selected scans'
|
||||
}
|
||||
>
|
||||
<GitCompare className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
Compare ({selected.length}/2)
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-border"
|
||||
onClick={load}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn('w-4 h-4 mr-2', loading && 'animate-spin')}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Completed vulnerability scans on this node, grouped by image. Select two to compare.
|
||||
</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3 mb-4 flex-wrap">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" strokeWidth={1.5} />
|
||||
<Input
|
||||
placeholder="Search by image..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
{needsPagination && (
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.max(0, safePage - 1))}
|
||||
disabled={safePage === 0}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
<span className="text-xs font-mono tabular-nums text-stat-subtitle min-w-[3rem] text-center">
|
||||
{safePage + 1} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setPage(Math.min(totalPages - 1, safePage + 1))}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{groups.length === 0 && !loading ? (
|
||||
<div className="flex flex-col items-center justify-center text-center py-16 gap-2">
|
||||
<ShieldCheck className="w-8 h-8 text-muted-foreground" strokeWidth={1.5} />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{search
|
||||
? 'No completed scans match your search.'
|
||||
: 'No scans have completed on this node yet.'}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="max-h-[70vh]">
|
||||
<div className="space-y-5 pr-2">
|
||||
{pageGroups.map((group) => (
|
||||
<div key={group.image_ref}>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="font-mono text-sm truncate" title={group.image_ref}>
|
||||
{group.image_ref}
|
||||
</span>
|
||||
<span className="text-xs text-stat-subtitle">
|
||||
{group.scans.length} scan{group.scans.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[40px]" />
|
||||
<TableHead className="w-[180px]">Scanned</TableHead>
|
||||
<TableHead className="w-[120px]">Trigger</TableHead>
|
||||
<TableHead className="w-[120px]">Highest</TableHead>
|
||||
<TableHead className="w-[90px] text-right">Total</TableHead>
|
||||
<TableHead className="w-[90px] text-right">Fixable</TableHead>
|
||||
<TableHead />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.scans.map((scan) => {
|
||||
const isSelected = selected.includes(scan.id);
|
||||
return (
|
||||
<TableRow
|
||||
key={scan.id}
|
||||
className={cn(isSelected && 'bg-accent/30')}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => toggleSelect(scan.id)}
|
||||
aria-label={`Select scan ${scan.id}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{new Date(scan.scanned_at).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs capitalize">
|
||||
{scan.triggered_by}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{scan.highest_severity ? (
|
||||
<SeverityChip severity={scan.highest_severity} />
|
||||
) : (
|
||||
<span className="text-xs text-success font-mono">none</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums">
|
||||
{scan.total_vulnerabilities}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs tabular-nums text-success">
|
||||
{scan.fixable_count}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setInspectScanId(scan.id)}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<ScanComparisonSheet
|
||||
baselineScanId={compareIds?.[0] ?? null}
|
||||
currentScanId={compareIds?.[1] ?? null}
|
||||
onClose={() => setCompareIds(null)}
|
||||
/>
|
||||
|
||||
<VulnerabilityScanSheet
|
||||
scanId={inspectScanId}
|
||||
onClose={() => setInspectScanId(null)}
|
||||
canGenerateSbom={isPaid}
|
||||
canCompare={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<VulnerabilityScan | null>(null);
|
||||
const [details, setDetails] = useState<VulnerabilityDetail[]>([]);
|
||||
@@ -80,6 +85,10 @@ export function VulnerabilityScanSheet({
|
||||
const [severityFilter, setSeverityFilter] = useState<SeverityFilter>('ALL');
|
||||
const [page, setPage] = useState(0);
|
||||
const [downloadingSbom, setDownloadingSbom] = useState(false);
|
||||
const [compareOpen, setCompareOpen] = useState(false);
|
||||
const [compareOptions, setCompareOptions] = useState<VulnerabilityScan[]>([]);
|
||||
const [compareLoading, setCompareLoading] = useState(false);
|
||||
const [compareBaselineId, setCompareBaselineId] = useState<number | null>(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({
|
||||
<Download className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
||||
CSV
|
||||
</Button>
|
||||
{canCompare && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openCompareMenu}
|
||||
disabled={compareLoading}
|
||||
title="Compare this scan to a previous one for the same image"
|
||||
>
|
||||
{compareLoading ? (
|
||||
<Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<GitCompare className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
||||
)}
|
||||
Compare
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{compareOpen && canCompare && (
|
||||
<div className="pt-2 space-y-2">
|
||||
{compareOptions.length === 0 && !compareLoading ? (
|
||||
<div className="rounded border border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
|
||||
No other completed scans for this image yet. Run a second scan to enable comparison.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs text-stat-subtitle uppercase tracking-wide">
|
||||
Compare against
|
||||
</div>
|
||||
<Combobox
|
||||
options={compareOptions.map((s) => ({
|
||||
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..."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Severity filter tabs */}
|
||||
@@ -416,6 +493,11 @@ export function VulnerabilityScanSheet({
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
<ScanComparisonSheet
|
||||
baselineScanId={compareBaselineId}
|
||||
currentScanId={compareBaselineId != null ? scanId : null}
|
||||
onClose={() => setCompareBaselineId(null)}
|
||||
/>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ScanCompareVulnerability, 'vulnerability_id' | 'pkg_name' | 'severity'>[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user